348 files
This commit is contained in:
@@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read(
|
||||
|
||||
Allowed:
|
||||
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
|
||||
- Files in allowed directories: www/, themes/, custom_templates/
|
||||
- Files in allowed directories: www/, themes/, custom_templates/,
|
||||
dashboards/, blueprints/ (blueprints is read-only — issue #1965)
|
||||
- Files matching patterns: <packages-folder>/*.yaml, custom_components/**/*.py
|
||||
- User-configured extra directories (``extra_dirs``), granted read+write
|
||||
(issue #1567)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
|
||||
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.2.2"
|
||||
COMPONENT_VERSION = "1.2.3"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
@@ -40,8 +40,17 @@ TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
|
||||
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
|
||||
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
|
||||
|
||||
# Allowed directories for file operations (relative to config dir)
|
||||
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
|
||||
# Allowed directories for file operations (relative to config dir).
|
||||
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
|
||||
# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint
|
||||
# reads are safe: community YAML, no secrets — issue #1965). This is the default
|
||||
# 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.
|
||||
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
|
||||
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
|
||||
|
||||
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
|
||||
|
||||
@@ -1568,6 +1568,64 @@ _PENDING_INSTALL_DONE: threading.Event | None = None
|
||||
_CACHED_IMPORT_VERSION: str | None = None
|
||||
|
||||
|
||||
def _safe_invalidate_caches() -> None:
|
||||
"""Run ``importlib.invalidate_caches()``, completing it if a finder breaks.
|
||||
|
||||
On Python 3.14 with ``homeassistant`` installed as a setuptools *editable*
|
||||
package (the official HA container image), ``PathFinder.invalidate_caches()``
|
||||
raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the
|
||||
synthetic ``__editable__.<dist>.finder.__path_hook__`` placeholder is not an
|
||||
absolute path, so CPython takes the ``del`` branch on a key an earlier
|
||||
iteration already removed — a CPython 3.14 bug (the ``del`` should be a
|
||||
``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before
|
||||
this guard, crashed in-process server bring-up on every boot (issues #1891,
|
||||
#1985).
|
||||
|
||||
On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead /
|
||||
relative ``sys.path_importer_cache`` entries with ``pop`` instead of the
|
||||
buggy ``del`` (the stale placeholder that trips the sweep is among them) —
|
||||
then re-run ``importlib.invalidate_caches()``. With the offending entries
|
||||
gone the retry completes CPython's full sweep itself: every live path-entry
|
||||
finder invalidated, the namespace-path epoch advanced, and the metadata
|
||||
finder refreshed. Delegating the second pass keeps us off private internals
|
||||
(no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the
|
||||
running Python's ``invalidate_caches`` does. Recovery only ever runs on the
|
||||
broken 3.14 path (the top-level call succeeds everywhere else); every
|
||||
non-``KeyError`` still propagates.
|
||||
|
||||
The retry is itself guarded: a concurrent import on another HA-core thread
|
||||
could re-add a stale placeholder in the window between the prune and the
|
||||
retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather
|
||||
than re-raised — a partial cache refresh must never re-crash bring-up, which
|
||||
is the whole point of this helper. The recovery is logged at WARNING (it
|
||||
recurs on every version check on an affected install), so it is visible for
|
||||
diagnosis rather than a silent workaround.
|
||||
"""
|
||||
try:
|
||||
importlib.invalidate_caches()
|
||||
return
|
||||
except KeyError as err:
|
||||
_LOGGER.warning(
|
||||
"importlib.invalidate_caches() raised KeyError from a broken "
|
||||
"(setuptools editable / Python 3.14) finder; pruning stale "
|
||||
"sys.path_importer_cache entries and retrying: %s",
|
||||
err,
|
||||
)
|
||||
for name in list(sys.path_importer_cache):
|
||||
if sys.path_importer_cache.get(name) is None or not os.path.isabs(name):
|
||||
sys.path_importer_cache.pop(name, None)
|
||||
try:
|
||||
importlib.invalidate_caches()
|
||||
except KeyError as err:
|
||||
_LOGGER.warning(
|
||||
"importlib.invalidate_caches() still raised KeyError after pruning "
|
||||
"stale sys.path_importer_cache entries; continuing with a best-effort "
|
||||
"cache state (a concurrent import may have re-added the placeholder): "
|
||||
"%s",
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
def _purge_ha_mcp_modules() -> None:
|
||||
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
|
||||
|
||||
@@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None:
|
||||
return
|
||||
for name in purged:
|
||||
sys.modules.pop(name, None)
|
||||
importlib.invalidate_caches()
|
||||
_safe_invalidate_caches()
|
||||
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
|
||||
|
||||
|
||||
@@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
|
||||
provided, checks that channel first so stale metadata from a failed
|
||||
best-effort conflicting uninstall cannot mask the package just installed.
|
||||
"""
|
||||
importlib.invalidate_caches()
|
||||
_safe_invalidate_caches()
|
||||
# Metadata alone is not proof: a channel switch's best-effort uninstall
|
||||
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
|
||||
# tree belongs to whichever dist installed last). Require the import
|
||||
@@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool:
|
||||
|
||||
Invalidates the import caches first so a just-completed (un)install is seen.
|
||||
"""
|
||||
importlib.invalidate_caches()
|
||||
_safe_invalidate_caches()
|
||||
try:
|
||||
importlib.metadata.version(dist_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
@@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None:
|
||||
the auto-update check compares the newest PyPI build against the version of
|
||||
the channel actually installed.
|
||||
"""
|
||||
importlib.invalidate_caches()
|
||||
_safe_invalidate_caches()
|
||||
try:
|
||||
return importlib.metadata.version(dist_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.2.2"
|
||||
"version": "1.2.3"
|
||||
}
|
||||
|
||||
@@ -486,6 +486,13 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
"""
|
||||
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
# Set the flag only AFTER every view registers (issue #1978): it must mean
|
||||
# "the full bundle is bound", so a partial bind stays distinguishable from a
|
||||
# complete one. Marking it bound early would let a later setup assign a
|
||||
# provider and advertise discovery while some RFC metadata routes are still
|
||||
# unbound — a 404 for the clients that probe them. On a partial bind the flag
|
||||
# stays unset; the none-mode caller then fails open (the retry's duplicate
|
||||
# register is caught harmlessly) while ha_auth/legacy fail closed.
|
||||
for view in _metadata_views(hass):
|
||||
hass.http.register_view(view)
|
||||
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
|
||||
@@ -735,9 +742,28 @@ async def async_register_webhook(
|
||||
# login (issue #1969). Both view bundles bind at most once per
|
||||
# HA session; the per-request resolvers gate them on this cfg,
|
||||
# so a none<->ha_auth switch needs no restart.
|
||||
_register_metadata_views(hass)
|
||||
bind_autoapprove_views(hass)
|
||||
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
|
||||
#
|
||||
# Fails OPEN, unlike ha_auth/legacy (issue #1978): none mode is
|
||||
# intentionally unauthenticated, and this discovery is an
|
||||
# enhancement layered on a webhook that (already registered
|
||||
# above) otherwise always forwards. A failure here must NOT fall
|
||||
# through to the outer teardown and take down a webhook the user
|
||||
# configured to need no auth — it only means claude.ai's rare
|
||||
# OAuth-discovery fallback goes unassisted. Mirrors the add-on's
|
||||
# _setup_none_autoapprove. Provider is assigned last, so a
|
||||
# partial bind leaves none-autoapprove inactive (plain proxy)
|
||||
# rather than half-enabled.
|
||||
try:
|
||||
_register_metadata_views(hass)
|
||||
bind_autoapprove_views(hass)
|
||||
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"MCP webhook: failed to set up none-mode auto-approve "
|
||||
"discovery; continuing as a plain unauthenticated proxy "
|
||||
"(the webhook still forwards — only claude.ai's rare "
|
||||
"OAuth-discovery fallback is unassisted)."
|
||||
)
|
||||
except Exception:
|
||||
# Never leave a live endpoint (or a leaked session) behind a failed
|
||||
# auth-setup path. suppress: the ORIGINAL error must be what
|
||||
|
||||
@@ -185,6 +185,18 @@ class AutoApproveAuthorizeView(HomeAssistantView):
|
||||
open-redirect gate, then issues a PKCE-bound one-time code and redirects
|
||||
straight back to the client. No login page and no consent screen render, so
|
||||
claude.ai's OAuth flow completes invisibly (issue #1969).
|
||||
|
||||
ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
|
||||
mode requires zero HA login — so it consults neither the webhook id nor a
|
||||
client identity. Anyone who knows the HA origin can therefore fill the
|
||||
shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
|
||||
to the public claude.ai callback, at which point a *brand-new* connector's
|
||||
handshake gets ``temporarily_unavailable`` until those codes expire
|
||||
(``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
|
||||
data, and grants no access: completing the flow needs the PKCE verifier the
|
||||
attacker never has, and the issued token is cosmetic (none mode ignores
|
||||
bearers). The webhook URL itself keeps forwarding throughout — only the rare
|
||||
OAuth-discovery fallback for a *first* connect is briefly delayed.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
@@ -298,6 +310,11 @@ def bind_autoapprove_views(hass: HomeAssistant) -> None:
|
||||
"""
|
||||
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
# Set the flag only AFTER both views register (issue #1978): see
|
||||
# mcp_webhook._register_metadata_views. Marking the bundle bound before
|
||||
# /token registers would let a later none-mode setup assign the provider and
|
||||
# advertise OAuth with an unbound /token — a 404 on the token exchange. The
|
||||
# flag must mean the full bundle succeeded; a partial bind leaves it unset.
|
||||
hass.http.register_view(AutoApproveAuthorizeView(hass))
|
||||
hass.http.register_view(AutoApproveTokenView(hass))
|
||||
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
|
||||
|
||||
@@ -436,7 +436,11 @@ class PKCECodeStore:
|
||||
Returns True only for a live, unexpired code whose stored
|
||||
``redirect_uri`` matches and whose ``code_challenge`` equals
|
||||
``base64url(SHA-256(code_verifier))``. The code is popped (one-shot)
|
||||
before any check that can fail, so a failed attempt still burns it.
|
||||
once the verifier clears the cheap RFC 7636 shape guard below, so every
|
||||
well-formed attempt burns it — a wrong verifier, expired code, or
|
||||
redirect mismatch still consumes the code. A malformed verifier is
|
||||
rejected before the pop and does NOT burn the code, so a client that
|
||||
sends a syntactically broken verifier can retry.
|
||||
"""
|
||||
# Validate the verifier shape per RFC 7636 §4.1 before doing any
|
||||
# crypto. A confused client passing an empty/short verifier should be
|
||||
|
||||
@@ -58,7 +58,10 @@ list_files:
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: Relative path from config directory (e.g., "www/", "themes/")
|
||||
description: >-
|
||||
Relative path from config directory. Allowed: www/, themes/,
|
||||
custom_templates/, dashboards/, blueprints/, plus your configured
|
||||
packages/ folder (e.g., "www/", "blueprints/automation").
|
||||
required: true
|
||||
example: "www/"
|
||||
selector:
|
||||
@@ -81,7 +84,8 @@ read_file:
|
||||
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/**,
|
||||
custom_templates/**, packages/*.yaml, custom_components/**/*.py
|
||||
custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
|
||||
custom_components/**/*.py
|
||||
required: true
|
||||
example: "configuration.yaml"
|
||||
selector:
|
||||
@@ -119,13 +123,13 @@ read_file:
|
||||
|
||||
write_file:
|
||||
name: Write File
|
||||
description: Write a file to allowed directories (www/, themes/, custom_templates/).
|
||||
description: Write a file to allowed directories (www/, themes/, custom_templates/, dashboards/).
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: >-
|
||||
Relative path from config directory. Must be in www/, themes/, or
|
||||
custom_templates/.
|
||||
Relative path from config directory. Must be in www/, themes/,
|
||||
custom_templates/, or dashboards/.
|
||||
required: true
|
||||
example: "www/custom.css"
|
||||
selector:
|
||||
@@ -155,13 +159,13 @@ write_file:
|
||||
|
||||
delete_file:
|
||||
name: Delete File
|
||||
description: Delete a file from allowed directories (www/, themes/, custom_templates/).
|
||||
description: Delete a file from allowed directories (www/, themes/, custom_templates/, dashboards/).
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: >-
|
||||
Relative path from config directory. Must be in www/, themes/, or
|
||||
custom_templates/.
|
||||
Relative path from config directory. Must be in www/, themes/,
|
||||
custom_templates/, or dashboards/.
|
||||
required: true
|
||||
example: "www/old-file.css"
|
||||
selector:
|
||||
|
||||
Reference in New Issue
Block a user