Skip to content

Commit 1ac7af2

Browse files
committed
Merge branch 'pr-1126' into addon-repo
2 parents a7355c8 + 4463d7d commit 1ac7af2

5 files changed

Lines changed: 914 additions & 35 deletions

File tree

homeassistant-addon-dev/config.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ ingress: true
1414
ingress_port: 9583
1515
ingress_stream: true
1616
hassio_api: true
17-
hassio_role: default
17+
# `manager` (not `default`) is required so the Supervisor token grants access
18+
# to /addons/<slug>/logs and /<service>/logs (system service logs). Verified
19+
# via live test — `default` returns 403 for both. See #1116.
20+
hassio_role: manager
1821
homeassistant_api: true
1922
host_network: true
2023
image: "ghcr.io/homeassistant-ai/ha-mcp-addon-dev-{arch}"

homeassistant-addon/config.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ startup: application
1313
boot: manual
1414
# Enable access to Supervisor API for auto-discovery
1515
hassio_api: true
16-
hassio_role: default
16+
# `manager` (not `default`) is required so the Supervisor token grants access
17+
# to /addons/<slug>/logs and /<service>/logs (system service logs). Verified
18+
# via live test — `default` returns 403 for both. See #1116.
19+
hassio_role: manager
1720
# Enable access to Home Assistant API
1821
homeassistant_api: true
1922
# Enable host network mode for local network access

src/ha_mcp/client/rest_client.py

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import asyncio
66
import json
77
import logging
8+
import os
89
import ssl
910
from typing import Any
1011

1112
import httpx
1213

14+
from .._version import is_running_in_addon
1315
from ..config import get_global_settings
1416

1517

@@ -434,28 +436,184 @@ async def get_error_log(self) -> str:
434436
return response if isinstance(response, str) else str(response)
435437

436438
async def get_addon_logs(self, slug: str) -> str:
437-
"""Fetch an add-on's container logs via HA Core's Supervisor REST proxy.
439+
"""Fetch an add-on's container logs.
438440
439-
Uses `/api/hassio/addons/{slug}/logs`, which HA Core proxies to
440-
Supervisor and returns as `text/plain`. This avoids the
441-
`supervisor/api` websocket path that tries to JSON-decode the text
442-
body and always fails (see #950).
441+
Branch on ``is_running_in_addon()`` (which keys off ``SUPERVISOR_TOKEN``
442+
in env): inside the add-on container goes directly to the Supervisor
443+
REST API at ``http://supervisor/addons/{slug}/logs`` with the
444+
Supervisor token. The HA Core proxy at
445+
``/api/hassio/addons/{slug}/logs`` rejects this token+path combination
446+
on current HA Core releases (see #1116) — the direct path bypasses
447+
HA Core entirely and is the documented Supervisor contract.
448+
449+
On non-addon installs (Docker, pyinstaller, pip pointing at a normal
450+
HA URL), falls back to the HA Core proxy path. That path requires an
451+
admin LLA but works fine when not invoked from the add-on container.
452+
453+
Both branches return ``text/plain`` log content.
443454
444455
Raises:
445-
HomeAssistantAuthError: 401 from HA Core.
446-
HomeAssistantAPIError: Non-2xx response (e.g. 404 unknown slug,
447-
400 addon not installed). `status_code` is set so callers
448-
can map to specific suggestions.
456+
HomeAssistantAuthError: 401 response, or ``SUPERVISOR_TOKEN`` empty
457+
at call time on the addon branch.
458+
HomeAssistantAPIError: 403 (role too low — addon needs hassio_role
459+
``manager``), 404 (unknown slug), or other non-2xx. The
460+
``status_code`` attribute lets callers map to specific
461+
suggestions.
449462
HomeAssistantConnectionError: Network, timeout, or transport error.
450463
"""
451-
logger.debug(f"Fetching addon logs for slug={slug}")
464+
if is_running_in_addon():
465+
return await self._get_addon_logs_via_supervisor(slug)
466+
467+
logger.debug(f"Fetching addon logs for slug={slug} via HA Core proxy")
452468
response = await self._raw_request(
453469
"GET",
454470
f"/hassio/addons/{slug}/logs",
455471
headers={"Accept": "text/plain"},
456472
)
457473
return response.text
458474

475+
async def _supervisor_logs_get(self, path: str) -> str:
476+
"""Fetch ``text/plain`` logs from a Supervisor REST endpoint.
477+
478+
``path`` is everything between ``http://supervisor/`` and ``/logs``:
479+
480+
- ``"addons/<slug>"`` for add-on container logs
481+
- ``"<service>"`` (where service ∈ {supervisor, host, core, dns, audio,
482+
multicast, observer}) for system-service logs
483+
484+
Bypasses ``HomeAssistantClient.httpx_client`` because the Supervisor
485+
endpoint uses a different base URL (``http://supervisor``) and a
486+
different token (``SUPERVISOR_TOKEN``) than HA Core REST. Both
487+
endpoints require the addon's ``hassio_role`` to be ``manager`` (not
488+
``default``); a ``default`` role gets a 403 here — see #1116.
489+
490+
Raises:
491+
HomeAssistantAuthError: ``SUPERVISOR_TOKEN`` absent at call time,
492+
or 401 from Supervisor.
493+
HomeAssistantAPIError: 403 (role too low — distinct branch with
494+
role hint), 404, other 4xx/5xx. Tries to parse Supervisor's
495+
``{"result":"error","message":"..."}`` JSON envelope before
496+
falling back to text body / reason phrase / placeholder.
497+
HomeAssistantConnectionError: Timeout or transport error, with
498+
distinct messages so callers can tell them apart.
499+
"""
500+
token = os.environ.get("SUPERVISOR_TOKEN", "")
501+
if not token:
502+
# The is_running_in_addon() gate already keys off SUPERVISOR_TOKEN
503+
# being truthy, so a direct caller landing here without one is a
504+
# detection/config mismatch — fail-fast with a distinct message
505+
# so operators don't read it as "token rejected".
506+
raise HomeAssistantAuthError(
507+
f"Supervisor token absent at call time for /{path}/logs "
508+
"(addon-mode gate fired but SUPERVISOR_TOKEN env var not set)"
509+
)
510+
511+
url = f"http://supervisor/{path}/logs"
512+
logger.debug("Fetching %s via Supervisor direct", url)
513+
514+
try:
515+
async with httpx.AsyncClient(
516+
timeout=httpx.Timeout(self.timeout),
517+
# `verify` is a no-op for plain http://supervisor, but kept
518+
# for symmetry with the other two direct-Supervisor httpx
519+
# clients (#1128 establishes the 3-site convention).
520+
verify=self.verify_ssl,
521+
) as client:
522+
response = await client.get(
523+
url,
524+
headers={
525+
"Authorization": f"Bearer {token}",
526+
"Accept": "text/plain",
527+
},
528+
)
529+
except httpx.TimeoutException as e:
530+
raise HomeAssistantConnectionError(
531+
f"Timeout fetching /{path}/logs from Supervisor: {e}"
532+
) from e
533+
except httpx.HTTPError as e:
534+
raise HomeAssistantConnectionError(
535+
f"Transport error fetching /{path}/logs from Supervisor: {e}"
536+
) from e
537+
538+
if response.status_code == 401:
539+
raise HomeAssistantAuthError(
540+
f"Invalid Supervisor token for /{path}/logs"
541+
)
542+
if response.status_code == 403:
543+
# Distinct from 401: token is valid but addon's hassio_role isn't
544+
# high enough. Most-likely cause for this exact endpoint at the
545+
# time #1116 surfaced (default → manager bump in addon config.yaml
546+
# is the same-PR companion fix).
547+
logger.warning(
548+
"Supervisor returned 403 for /%s/logs — addon hassio_role may "
549+
"be too low (need 'manager')",
550+
path,
551+
)
552+
raise HomeAssistantAPIError(
553+
f"Supervisor forbids /{path}/logs (403) — addon's hassio_role "
554+
"may be 'default'; need 'manager' or higher",
555+
status_code=403,
556+
response_data={"path": path},
557+
)
558+
if response.status_code >= 400:
559+
text_body = response.text
560+
# Supervisor returns {"result":"error","message":"..."} JSON on
561+
# some 4xx paths. Try parsing that first so the user sees the
562+
# human message instead of a JSON blob; then fall back to the
563+
# text body, then reason_phrase, then a placeholder.
564+
message = ""
565+
try:
566+
envelope = json.loads(text_body) if text_body else None
567+
if isinstance(envelope, dict):
568+
msg = envelope.get("message")
569+
if isinstance(msg, str) and msg:
570+
message = msg
571+
except json.JSONDecodeError:
572+
pass
573+
if not message:
574+
message = (
575+
text_body.strip() or response.reason_phrase or "<empty body>"
576+
)
577+
logger.warning(
578+
"Supervisor returned %s for /%s/logs: %s",
579+
response.status_code, path, message,
580+
)
581+
raise HomeAssistantAPIError(
582+
f"API error: {response.status_code} - {message}",
583+
status_code=response.status_code,
584+
response_data={"message": text_body, "path": path},
585+
)
586+
return response.text
587+
588+
async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
589+
"""Fetch add-on container logs directly from Supervisor's REST API.
590+
591+
Distinct from ``tools_bug_report._fetch_addon_logs``: that helper is
592+
hardcoded to ``/addons/self/logs`` and silently swallows failures
593+
(it's an aux-data fetch for bug reports, fine to skip on error). This
594+
helper takes arbitrary slugs and surfaces failures as exceptions
595+
because callers (``ha_get_logs(source="supervisor", slug=...)``) need
596+
them. Both endpoints require ``hassio_role: manager``.
597+
598+
Delegates to ``_supervisor_logs_get`` so error handling stays in
599+
lockstep with ``_get_system_service_logs``.
600+
"""
601+
return await self._supervisor_logs_get(f"addons/{slug}")
602+
603+
async def _get_system_service_logs(self, service: str) -> str:
604+
"""Fetch HA system-service logs directly from Supervisor's REST API.
605+
606+
Hits ``http://supervisor/{service}/logs``. ``service`` must be one of
607+
the seven Supervisor-managed services: ``supervisor``, ``host``,
608+
``core``, ``dns``, ``audio``, ``multicast``, ``observer``. Caller is
609+
responsible for validating ``service`` against the allowed set; this
610+
helper does no validation and will raise ``HomeAssistantAPIError`` on
611+
any unknown path (404 from Supervisor).
612+
613+
Requires ``hassio_role: manager`` like the addon-logs path.
614+
"""
615+
return await self._supervisor_logs_get(service)
616+
459617
async def test_connection(self) -> tuple[bool, str | None]:
460618
"""
461619
Test connection to Home Assistant.

0 commit comments

Comments
 (0)