Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+245 -30
View File
@@ -33,6 +33,9 @@ from .const import (
COMPONENT_MANIFEST_AT_TAG_URL,
DATA_BRINGUP_TASK,
DATA_MANAGER,
DATA_OAUTH_CLIENT_ID,
DATA_OAUTH_CLIENT_SECRET,
DATA_OAUTH_SIGNING_KEY,
DATA_PENDING_UPDATE_NOTIFY,
DATA_SECRET_PATH,
DATA_UPDATE_COORDINATOR,
@@ -43,8 +46,8 @@ from .const import (
DEFAULT_PIP_SPEC,
DEFAULT_SERVER_PORT,
DOMAIN,
HACS_COMPONENT_URL,
ISSUE_COMPONENT_OUTDATED,
ISSUE_LEGACY_OAUTH_RESTART,
ISSUE_PACKAGE_FAILED,
ISSUE_START_FAILED,
ISSUE_UPDATE_HELD,
@@ -58,12 +61,16 @@ from .const import (
OPT_PIP_SPEC,
OPT_SERVER_PORT,
OPT_WEBHOOK_AUTH,
UPDATE_HOLD_DOCS_URL,
WEBHOOK_AUTH_LEGACY,
WEBHOOK_AUTH_NONE,
channel_for_dist,
)
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
from .hacs_nudge import async_schedule_hacs_nudge
from .llm_api import async_register_llm_api, async_unregister_llm_api
from .mcp_webhook import async_register_webhook, async_unregister_webhook
from .oauth_legacy import legacy_credentials_active
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@@ -111,23 +118,51 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
secret_path = str(entry.data[DATA_SECRET_PATH])
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
oauth_client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
oauth_client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
# Always set up the loopback forwarding config — the sidebar settings
# panel proxies through it (#1803); the option gates only the public
# webhook endpoint.
await async_register_webhook(
# webhook endpoint. oauth_* args are ignored unless auth_mode is legacy.
oauth_restart_needed = await async_register_webhook(
hass,
entry,
port=manager.port,
secret_path=secret_path,
auth_mode=auth_mode,
register_endpoint=webhook_enabled,
oauth_client_id=oauth_client_id,
oauth_client_secret=oauth_client_secret,
oauth_signing_key=entry.data.get(DATA_OAUTH_SIGNING_KEY),
)
_async_update_legacy_oauth_issue(hass, oauth_restart_needed)
if not webhook_enabled:
_LOGGER.info(
"Webhook access disabled by option - the server is local-only "
"(direct port + sidebar panel)"
)
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
# Only surface cleartext credentials once the bound provider actually
# serves them: while a rotation is pending restart, an old-identity
# token still validates and can read this log (see
# legacy_credentials_active).
oauth_creds_active = True
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
oauth_creds_active = legacy_credentials_active(
hass,
str(oauth_client_id or ""),
str(oauth_client_secret or ""),
str(entry.data.get(DATA_OAUTH_SIGNING_KEY) or ""),
)
_surface_connect_urls(
hass,
entry,
auth_mode,
webhook_enabled=webhook_enabled,
extra_hosts=await async_get_lan_hosts(hass),
oauth_client_id=oauth_client_id,
oauth_client_secret=oauth_client_secret,
oauth_creds_active=oauth_creds_active,
oauth_restart_pending=oauth_restart_needed,
)
# Conversation-agent LLM API (#1745), gated on its option (default on).
# Advisory: registration failures are contained inside (logged, feature
# absent) — the running server must never be taken down by them.
@@ -189,6 +224,87 @@ async def async_revoke_credentials_on_remove(
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
_clear_issues(hass)
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
# Clear the legacy-OAuth restart repair too: it is filed only from bring-up,
# which never runs again for a removed entry, so a restart that was still
# pending at removal would otherwise leave a dangling warning for a server
# that no longer exists. (Re-enabling legacy on a fresh entry re-files it.)
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
async def async_get_lan_hosts(hass: HomeAssistant) -> list[str]:
"""Every IPv4 address on an enabled network adapter, in adapter order (#1862).
Lets the connect-URL surfaces list one entry per interface on a
multi-interface / multi-VLAN host, instead of only the single address
``get_url`` resolves. IPv4 only: ``async_get_adapters`` returns a bare
IPv6 ``address`` with a separate ``scope_id`` int, so a link-local adapter
address is not a usable URL host without rejoining that zone id (and every
IPv6 host additionally needs bracket-wrapping), so building those correctly
is out of scope; the reported setups are IPv4. Best-effort: any failure
yields an empty list so URL
surfacing degrades to the single ``get_url`` host rather than taking down
the caller (bring-up would otherwise file a repair issue for a display-only
lookup).
"""
try:
from homeassistant.components import network
adapters = await network.async_get_adapters(hass)
# The whole extraction is inside the try (not just the fetch): a
# malformed adapter entry must degrade to the single get_url host too,
# never escape into async_bring_up_server's handler, which would tear
# the running server down and file a start-failure for a display-only
# lookup.
return [
ipv4["address"]
for adapter in adapters
if adapter["enabled"]
for ipv4 in adapter["ipv4"]
]
except Exception: # display-only enumeration; must never fail the caller
_LOGGER.warning(
"Adapter enumeration failed; using the single resolved host",
exc_info=True,
)
return []
def _swap_url_host(base: str, host: str) -> str:
"""Return ``base`` with its host replaced by ``host`` (scheme/port/path kept)."""
parsed = urlparse(base)
netloc = host if parsed.port is None else f"{host}:{parsed.port}"
return parsed._replace(netloc=netloc).geturl()
def _dedup_hosts(primary: str | None, extra: list[str] | None) -> list[str]:
"""Ordered unique hosts, ``primary`` (the canonical get_url host) first."""
ordered: list[str] = []
for host in (primary, *(extra or [])):
if host and host not in ordered:
ordered.append(host)
return ordered
def _resolve_local_url(hass: HomeAssistant) -> tuple[str | None, str | None]:
"""The get_url internal base URL and its host, or ``(None, None)``."""
from homeassistant.helpers.network import NoURLAvailableError, get_url
try:
base = get_url(hass, allow_external=False, prefer_external=False)
except NoURLAvailableError:
return None, None # No internal/local URL configured - hint form instead.
return base, urlparse(base).hostname
def _local_webhook_urls(
local_base: str, local_host: str | None, lan_hosts: list[str], webhook_id: str
) -> list[str]:
"""One local webhook URL per LAN host (canonical ``local_base`` verbatim)."""
return [
f"{local_base if host == local_host else _swap_url_host(local_base, host)}"
f"/api/webhook/{webhook_id}"
for host in lan_hosts
]
def build_connect_urls(
@@ -196,6 +312,7 @@ def build_connect_urls(
entry: ConfigEntry,
*,
webhook_enabled: bool = True,
extra_hosts: list[str] | None = None,
) -> list[str]:
"""Resolve the entry's connect URLs (webhook forms first, then direct).
@@ -203,9 +320,13 @@ def build_connect_urls(
log on start-up and the entry's Configure screen (the notification
deliberately carries none - it is visible to every signed-in user). Each
source is best-effort: a URL that cannot be resolved is omitted.
"""
from homeassistant.helpers.network import NoURLAvailableError, get_url
``extra_hosts`` (from :func:`async_get_lan_hosts`) adds one webhook and one
direct-access URL per additional LAN address, so a multi-interface /
multi-VLAN host surfaces every reachable interface rather than only the
single host ``get_url`` picks (#1862). The ``get_url`` host stays canonical
and first; any repeat of it in ``extra_hosts`` is deduped away.
"""
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
urls: list[str] = []
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
@@ -234,14 +355,15 @@ def build_connect_urls(
except ImportError:
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
local_host: str | None = None
try:
local_base = get_url(hass, allow_external=False, prefer_external=False)
local_host = urlparse(local_base).hostname
if webhook_id:
urls.append(f"{local_base}/api/webhook/{webhook_id}")
except NoURLAvailableError:
pass # No internal/local URL configured - fall through to the hint form.
local_base, local_host = _resolve_local_url(hass)
# One entry per enabled LAN address so a multi-interface / multi-VLAN host
# surfaces every reachable interface rather than get_url's single pick
# (#1862). The get_url host stays canonical and first.
lan_hosts = _dedup_hosts(local_host, extra_hosts)
if local_base and webhook_id:
urls.extend(_local_webhook_urls(local_base, local_host, lan_hosts, webhook_id))
if not urls and webhook_id:
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
@@ -253,9 +375,9 @@ def build_connect_urls(
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
# Guarded on the secret path so a missing one omits the line instead of
# rendering a valid-looking URL without its credential segment.
urls.append(
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
" (direct access)"
urls.extend(
f"http://{host}:{port}{secret_path} (direct access)"
for host in lan_hosts or ["<home-assistant-ip>"]
)
return urls
@@ -266,23 +388,83 @@ def _surface_connect_urls(
auth_mode: str,
*,
webhook_enabled: bool = True,
extra_hosts: list[str] | None = None,
oauth_client_id: str | None = None,
oauth_client_secret: str | None = None,
oauth_creds_active: bool = True,
oauth_restart_pending: bool = False,
) -> None:
"""Log the connect URLs and (re)create a persistent notification."""
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
auth_note = (
"Webhook access is disabled (local-only mode)."
if not webhook_enabled
else "The webhook URL is the shared secret (no bearer required)."
if auth_mode == WEBHOOK_AUTH_NONE
else "Clients authenticate with your Home Assistant account (ha_auth)."
urls = build_connect_urls(
hass, entry, webhook_enabled=webhook_enabled, extra_hosts=extra_hosts
)
if not webhook_enabled:
auth_note = "Webhook access is disabled (local-only mode)."
elif auth_mode == WEBHOOK_AUTH_NONE:
auth_note = "The webhook URL is the shared secret (no bearer required)."
elif auth_mode == WEBHOOK_AUTH_LEGACY:
# Kept secret-free (unlike the log line below) — see the SECURITY note
# on the persistent notification further down, which reuses this text.
creds_where = (
"the Home Assistant log or the entry's Configure screen"
if oauth_creds_active
else "the entry's Configure screen"
)
auth_note = (
"OAuth (Beta) is ENABLED for this URL (legacy mode) - see "
f"{creds_where} for the Client ID and Client Secret to paste "
"into your MCP client."
)
else:
auth_note = "Clients authenticate with your Home Assistant account (ha_auth)."
url_lines = "\n".join(f"- {url}" for url in urls)
_LOGGER.info(
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
url_lines,
auth_note,
log_message = (
"HA-MCP in-process server is running. "
f"Connect URL(s):\n{url_lines}\n{auth_note}"
)
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
if oauth_creds_active:
# Admin-only log (mirrors the webhook-proxy add-on's own startup
# log, start.py). Cleartext credentials — deliberately NOT in the
# persistent notification below, which every signed-in user can
# see.
log_message += (
f"\n OAuth Client ID: {oauth_client_id}"
f"\n OAuth Client Secret: {oauth_client_secret}"
)
if oauth_restart_pending:
# First-enable mid-session late-binds the root views, so
# /authorize is not live until the restart the repair asks
# for. The credentials ARE the ones that will be served
# (oauth_creds_active is True), but pasting them now gets a
# connection that fails until the restart — same caveat the
# rotation branch, the options hint, and the oauth_regenerate
# help text carry.
log_message += (
"\n Legacy OAuth is not live until the restart Home "
"Assistant is asking for; these credentials work once "
"you restart."
)
log_message += (
"\n Paste both into your MCP client's OAuth connector setup "
"(e.g. Google Gemini Spark: Advanced settings)."
)
else:
# SECURITY (review finding on #1880): while a credential rotation
# is pending the restart, the bound root views still serve the OLD
# identity, so a token issued under it stays valid — and could
# read this log through the server's own log tools. Logging the
# NEW credentials here would hand them to exactly the party the
# rotation is meant to evict, so they are withheld until the
# restart makes them active (which also kills every old token).
log_message += (
"\n The OAuth credentials were rotated and take effect after "
"the restart Home Assistant is asking for; until then the "
"previous credentials remain active. The new Client ID and "
"Client Secret are on the entry's Configure screen."
)
_LOGGER.info(log_message)
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
# Notification suppressed by option: clear any notification created
# before the toggle was turned off, then skip creating a fresh one. The
@@ -322,6 +504,29 @@ def _surface_connect_urls(
)
def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool) -> None:
"""File/clear the legacy-OAuth restart repair per ``async_register_webhook``'s
return value.
Raised on BOTH transitions (see that function's docstring): enabling
legacy mode (the root views just bound, or bound with different
credentials than before) and disabling it (the views are still bound from
a prior legacy registration). aiohttp can neither bind nor release an HTTP
view without a full Home Assistant restart either way.
"""
if restart_needed:
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_LEGACY_OAUTH_RESTART,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_LEGACY_OAUTH_RESTART,
)
else:
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
_ISSUE_BY_KIND = {
"package": ISSUE_PACKAGE_FAILED,
"start": ISSUE_START_FAILED,
@@ -450,8 +655,13 @@ async def async_maybe_auto_update(
"shipped": shipped,
"running": running,
},
learn_more_url=HACS_COMPONENT_URL,
learn_more_url=UPDATE_HOLD_DOCS_URL,
)
# A newer component exists but HACS may not surface it for ~48h; ask
# HACS to refresh this repository now so the update becomes visible
# promptly. Fire-and-forget + throttled per shipped version; fully
# advisory (see hacs_nudge).
async_schedule_hacs_nudge(hass, shipped)
return
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
@@ -707,7 +917,12 @@ async def _async_check_component_compat(
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_COMPONENT_OUTDATED,
translation_placeholders={"required": required, "installed": own},
learn_more_url=HACS_COMPONENT_URL,
learn_more_url=UPDATE_HOLD_DOCS_URL,
)
# The server needs a newer component than HACS has surfaced; ask HACS
# to refresh this repository now so the required update becomes visible
# promptly. Fire-and-forget + throttled per required version; fully
# advisory (see hacs_nudge).
async_schedule_hacs_nudge(hass, required)
else:
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)