Skip to content

Commit cc708f0

Browse files
fix: HA Core proxy fallback for ha_get_logs(source=system_service) on non-addon installs (#1283)
* fix: HA Core proxy fallback for ha_get_logs(source=system_service) on non-addon installs Closes #1260. Pre-fix `_get_system_service_logs` had only the Supervisor-direct branch, so non-addon installs (the Docker image, uvx ha-mcp, pip-based deploys) fell straight through to the SUPERVISOR_TOKEN-absent fail-fast in `_supervisor_logs_get` for every system-service slug. Sibling `get_addon_logs` and `get_error_log` already had the `is_running_in_addon()` gate plus HA Core proxy fallback added in PR #1126; this PR closes the parallel gap. The reproduced symptom matched the issue exactly on a non-addon install (uvx ha-mcp pointed at a Supervisor-equipped HA): `source="supervisor"` worked via the existing Core proxy fallback, while `source="system_service"` with slug in {supervisor, host, core} returned AUTH_INVALID_TOKEN with the misleading "addon-mode gate fired but SUPERVISOR_TOKEN env var not set" message. Once the gate is added, that fail-fast string is accurate again because the only callers reaching it are addon-mode-confirmed. Verified the proxy path is reachable for all seven service slugs by reading HA Core's `homeassistant/components/hassio/http.py` — PATHS_ADMIN whitelists `{audio,cli,core,dns,host,multicast,observer,supervisor}/logs` plus `addons/{slug}/logs`. Admin LLA is sufficient. (HA Core also proxies `cli/logs`, which ha-mcp's SYSTEM_SERVICE_SLUGS doesn't include — leaving that out as scope for a separate PR if anyone wants CLI logs surfaced.) Tests: new TestGetSystemServiceLogsBranchSelection class mirrors the existing TestGetAddonLogsBranchSelection / TestGetErrorLogBranchSelection shape — non-addon branch parametrized over all seven slugs, plus an addon-branch test that proxy is not called. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(rest_client): Accept header + 404 path on system_service non-addon branch Two parity additions in TestGetSystemServiceLogsBranchSelection, per PR review by pr-test-analyzer: - Parametrized happy-path test now asserts `Accept: text/plain` on the proxy request. Without it the HA Core proxy negotiates application/json and the body stops being raw log text (same silent-failure signature #950 describes one layer up). - New `test_non_addon_install_404_raises_api_error_with_service_context` anchors the live "observer returned 404 on hubs that don't run it" case from the #1260 end-to-end verification. Guards against a future refactor wrapping the proxy call in a swallow-and-return-empty try/except in `_get_system_service_logs`. Parallels `TestGetAddonLogs::test_raises_api_error_on_404_with_slug_context`. 64 tests pass in this file (was 63), ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(get_logs): branch-aware error suggestions + add cli to system_service slugs Two follow-ons from the PR #1283 review, both addressing review findings that were initially deferred as "future improvements" but are real behavior issues introduced or exposed by this PR's branch split: 1. Branch-aware wrapper suggestions in tools_utility.py: Pre-fix, the AuthError and 403 branches in `_get_supervisor_log` and `_get_system_service_log` always emitted SUPERVISOR_TOKEN/hassio_role hints — useless on non-addon installs that hit the new HA Core proxy path. With #1283's gate-and-fallback split, the wrapper now gates on `is_running_in_addon()` and picks branch-appropriate suggestions: - In-addon AuthError → "Verify SUPERVISOR_TOKEN..." (unchanged) - Non-addon AuthError → "Verify HOMEASSISTANT_TOKEN is a valid admin Long-Lived Access Token..." - In-addon 403 (system_service) → "Addon's hassio_role must be 'manager'..." (unchanged) - Non-addon 403 (system_service) → "The LLA must belong to an admin..." Same shape applied to `_get_supervisor_log` so source="supervisor" doesn't have the same dead-end advice on Docker/uvx installs either. 2. Add `cli` to SYSTEM_SERVICE_SLUGS: HA Core's hassio HTTP proxy already whitelists `cli/logs` in PATHS_ADMIN. Adding the slug surfaces Supervisor CLI logs via the same routes #1116 set up for the other seven services. Eight slugs total now. Tests: - Parametrized non-addon proxy test extended to 8 slugs (cli added) - `test_all_seven_allowed_services_dispatch` renamed → `_all_allowed_`, extended to 8 slugs - Existing `test_403_role_hint_suggestion` renamed to clarify it's the in-addon case + explicit `is_running_in_addon` mock added - 5 new tests pinning the branch-aware suggestion behavior (in-addon AuthError, non-addon AuthError, non-addon 403) on both wrappers - Supervisor mock updated to recognize the 8-slug set - Backtick consistency in TestGetAddonLogsBranchSelection + TestGetSystemServiceLogsBranchSelection class docstrings, per comment-analyzer S3 71 unit tests pass in test_tools_utility_supervisor_logs.py (was 63 pre-PR, 64 after first review-pass test additions). Ruff clean across all touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c44ba4 commit cc708f0

4 files changed

Lines changed: 380 additions & 54 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ async def _supervisor_logs_get(self, path: str) -> str:
527527
528528
- ``"addons/<slug>"`` for add-on container logs
529529
- ``"<service>"`` (where service ∈ {supervisor, host, core, dns, audio,
530-
multicast, observer}) for system-service logs
530+
cli, multicast, observer}) for system-service logs
531531
532532
Bypasses ``HomeAssistantClient.httpx_client`` because the Supervisor
533533
endpoint uses a different base URL (``http://supervisor``) and a
@@ -645,18 +645,42 @@ async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
645645
return await self._supervisor_logs_get(f"addons/{slug}")
646646

647647
async def _get_system_service_logs(self, service: str) -> str:
648-
"""Fetch HA system-service logs directly from Supervisor's REST API.
649-
650-
Hits ``http://supervisor/{service}/logs``. ``service`` must be one of
651-
the seven Supervisor-managed services: ``supervisor``, ``host``,
652-
``core``, ``dns``, ``audio``, ``multicast``, ``observer``. Caller is
653-
responsible for validating ``service`` against the allowed set; this
654-
helper does no validation and will raise ``HomeAssistantAPIError`` on
655-
any unknown path (404 from Supervisor).
656-
657-
Requires ``hassio_role: manager`` like the addon-logs path.
648+
"""Fetch HA system-service logs.
649+
650+
``service`` must be one of the eight Supervisor-managed services:
651+
``supervisor``, ``host``, ``core``, ``dns``, ``audio``, ``cli``,
652+
``multicast``, ``observer``. Caller is responsible for validating
653+
``service`` against the allowed set; this helper does no validation
654+
and will raise ``HomeAssistantAPIError`` on any unknown path (404).
655+
656+
Branch on ``is_running_in_addon()`` — mirror of ``get_addon_logs``:
657+
inside the addon container goes directly to Supervisor at
658+
``http://supervisor/{service}/logs`` with the Supervisor token
659+
(``hassio_role: manager`` required). On non-addon installs (Docker
660+
without Supervisor, pyinstaller, pip pointing at a normal HA URL),
661+
falls back to the HA Core proxy at ``/api/hassio/{service}/logs``.
662+
663+
All seven slugs are whitelisted in HA Core's hassio proxy
664+
(``homeassistant/components/hassio/http.py`` — ``PATHS_ADMIN``), so
665+
an admin LLA is sufficient to reach any of them from outside the
666+
addon.
667+
668+
Closes #1260: pre-fix this method had only the addon-direct branch,
669+
so non-addon installs (the Docker image, uvx ha-mcp, etc.) hit the
670+
``SUPERVISOR_TOKEN``-absent fail-fast in ``_supervisor_logs_get`` for
671+
every service, while the sibling ``source="supervisor"`` (addon
672+
logs) call kept working through its own Core-proxy fallback.
658673
"""
659-
return await self._supervisor_logs_get(service)
674+
if is_running_in_addon():
675+
return await self._supervisor_logs_get(service)
676+
677+
logger.debug(f"Fetching {service} logs via HA Core proxy")
678+
response = await self._raw_request(
679+
"GET",
680+
f"/hassio/{service}/logs",
681+
headers={"Accept": "text/plain"},
682+
)
683+
return response.text
660684

661685
async def test_connection(self) -> tuple[bool, str | None]:
662686
"""

src/ha_mcp/tools/tools_utility.py

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from fastmcp.exceptions import ToolError
1414

15+
from .._version import is_running_in_addon
1516
from ..client.rest_client import (
1617
HomeAssistantAPIError,
1718
HomeAssistantAuthError,
@@ -42,11 +43,12 @@
4243
}
4344

4445

45-
# Supervisor-managed system services exposed via /<slug>/logs. Stable set
46-
# in HA Core; if Supervisor adds e.g. /cli/logs in a future release, extend
47-
# here. See #1116.
46+
# Supervisor-managed system services exposed via /<slug>/logs. Set mirrors
47+
# HA Core's hassio HTTP proxy ``PATHS_ADMIN`` whitelist in
48+
# ``homeassistant/components/hassio/http.py``. See #1116 (original 7-service
49+
# scope) and #1260 (cli added — proxy supported it the whole time).
4850
SYSTEM_SERVICE_SLUGS = frozenset(
49-
{"supervisor", "host", "core", "dns", "audio", "multicast", "observer"}
51+
{"supervisor", "host", "core", "dns", "audio", "cli", "multicast", "observer"}
5052
)
5153

5254

@@ -145,7 +147,7 @@ async def ha_get_logs(
145147
- "error_log": Raw home-assistant.log text
146148
- "supervisor": Add-on container logs (requires slug = add-on slug)
147149
- "system_service": HA-Supervisor-managed system service logs (requires
148-
slug ∈ {supervisor, host, core, dns, audio, multicast, observer})
150+
slug ∈ {supervisor, host, core, dns, audio, cli, multicast, observer})
149151
- "logger": Effective log level per integration via logger/log_info (confirms logger.set_level changes took effect)
150152
151153
**Shared params:** limit, search (keyword filter on entries/lines; matches integration domain for source='logger')
@@ -762,15 +764,29 @@ async def _get_supervisor_log(
762764
except HomeAssistantAuthError as e:
763765
# Listed before HomeAssistantAPIError because AuthError is a sibling,
764766
# not a subclass — without this explicit clause the 401 from
765-
# _supervisor_logs_get propagates raw to FastMCP and surfaces
766-
# without a structured `code` field.
767+
# _supervisor_logs_get / _raw_request propagates raw to FastMCP and
768+
# surfaces without a structured `code` field.
769+
#
770+
# Suggestions branch on is_running_in_addon(): addon installs go
771+
# direct to Supervisor (the failure mode is a missing/rotated
772+
# SUPERVISOR_TOKEN), non-addon installs hit HA Core's hassio
773+
# proxy with the user's LLA (the failure mode is a non-admin or
774+
# expired LLA — SUPERVISOR_TOKEN doesn't even apply).
775+
if is_running_in_addon():
776+
suggestions = [
777+
"Verify SUPERVISOR_TOKEN is set correctly inside the add-on",
778+
"Reinstall the add-on if the token may have rotated",
779+
]
780+
else:
781+
suggestions = [
782+
"Verify HOMEASSISTANT_TOKEN is a valid admin Long-Lived "
783+
"Access Token (Settings → Profile → Long-Lived Access Tokens)",
784+
"Re-create the LLAT if it has expired or been revoked",
785+
]
767786
exception_to_structured_error(
768787
e,
769788
context={"source": "supervisor", "slug": slug},
770-
suggestions=[
771-
"Verify SUPERVISOR_TOKEN is set correctly inside the add-on",
772-
"Reinstall the add-on if the token may have rotated",
773-
],
789+
suggestions=suggestions,
774790
)
775791
except HomeAssistantAPIError as e:
776792
status = getattr(e, "status_code", None)
@@ -832,13 +848,15 @@ async def _get_system_service_log(
832848
) -> dict[str, Any]:
833849
"""Fetch HA system-service logs from Supervisor's per-service endpoint.
834850
835-
``service`` ∈ {supervisor, host, core, dns, audio, multicast, observer}.
851+
``service`` ∈ ``SYSTEM_SERVICE_SLUGS`` (the eight Supervisor-managed
852+
services: supervisor, host, core, dns, audio, cli, multicast, observer).
836853
Caller (``ha_get_logs(source='system_service')``) validates against
837-
``SYSTEM_SERVICE_SLUGS`` before dispatch. Hits
838-
``http://supervisor/<service>/logs`` directly via
839-
``HomeAssistantClient._get_system_service_logs`` — same direct-Supervisor
840-
path #1116's add-on fix uses, just with a different URL prefix.
841-
Requires ``hassio_role: manager`` in the addon manifest.
854+
``SYSTEM_SERVICE_SLUGS`` before dispatch. Routed through
855+
``HomeAssistantClient._get_system_service_logs`` which gates on
856+
``is_running_in_addon()``: addon installs hit Supervisor directly at
857+
``http://supervisor/<service>/logs`` (requires ``hassio_role: manager``
858+
in the addon manifest), non-addon installs fall back to the HA Core
859+
proxy at ``/api/hassio/<service>/logs`` (requires an admin LLA).
842860
"""
843861
effective_limit = _coerce_limit(
844862
limit, default=DEFAULT_LOG_LIMIT, suggestion_example="100"
@@ -877,29 +895,53 @@ async def _get_system_service_log(
877895
except HomeAssistantAuthError as e:
878896
# Listed before HomeAssistantAPIError because AuthError is a sibling,
879897
# not a subclass — without this explicit clause the 401 from
880-
# _supervisor_logs_get propagates raw to FastMCP and surfaces
881-
# without a structured `code` field.
898+
# _supervisor_logs_get / _raw_request propagates raw to FastMCP and
899+
# surfaces without a structured `code` field.
900+
#
901+
# Suggestions branch on is_running_in_addon() (see _get_supervisor_log
902+
# for the rationale): SUPERVISOR_TOKEN suggestions only make sense
903+
# inside the addon container; non-addon installs need admin-LLA hints.
904+
if is_running_in_addon():
905+
suggestions = [
906+
"Verify SUPERVISOR_TOKEN is set correctly inside the add-on",
907+
"Reinstall the add-on if the token may have rotated",
908+
]
909+
else:
910+
suggestions = [
911+
"Verify HOMEASSISTANT_TOKEN is a valid admin Long-Lived "
912+
"Access Token (Settings → Profile → Long-Lived Access Tokens)",
913+
"Re-create the LLAT if it has expired or been revoked",
914+
]
882915
exception_to_structured_error(
883916
e,
884917
context={"source": "system_service", "slug": service},
885-
suggestions=[
886-
"Verify SUPERVISOR_TOKEN is set correctly inside the add-on",
887-
"Reinstall the add-on if the token may have rotated",
888-
],
918+
suggestions=suggestions,
889919
)
890920
except HomeAssistantAPIError as e:
891921
status = getattr(e, "status_code", None)
892922
if status == 403:
893-
# Same role-too-low cause as the addon-logs branch.
894-
exception_to_structured_error(
895-
e,
896-
context={"source": "system_service", "slug": service},
897-
suggestions=[
923+
# In-addon: Supervisor returns 403 when the addon's hassio_role
924+
# is below 'manager'. Non-addon: HA Core's hassio proxy returns
925+
# 403 when the LLA's user lacks admin — completely different
926+
# remediation. Branch on the gate accordingly.
927+
if is_running_in_addon():
928+
suggestions = [
898929
"Addon's hassio_role must be 'manager' or higher to "
899930
"read /<service>/logs",
900931
"Verify the addon was reinstalled after the role bump "
901932
"took effect",
902-
],
933+
]
934+
else:
935+
suggestions = [
936+
"The Long-Lived Access Token must belong to a user "
937+
"with admin privileges",
938+
"Generate a new LLAT under an admin account and set "
939+
"HOMEASSISTANT_TOKEN to it",
940+
]
941+
exception_to_structured_error(
942+
e,
943+
context={"source": "system_service", "slug": service},
944+
suggestions=suggestions,
903945
)
904946
if status == 404:
905947
exception_to_structured_error(

tests/src/e2e/utilities/supervisor_mock.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Endpoints implemented (only what the code actually calls):
2121
2222
- ``GET /{service}/logs`` for service ∈ {supervisor, host, core, dns, audio,
23-
multicast, observer} — the seven Supervisor-managed system services
23+
cli, multicast, observer} — the eight Supervisor-managed system services
2424
- ``GET /addons/{slug}/logs`` and ``GET /addons/self/logs`` — addon container logs
2525
- ``POST /addons/self/restart`` — addon self-restart (Supervisor envelope reply)
2626
@@ -47,10 +47,10 @@
4747
# need to exercise the role-mismatch branch added alongside #1116.
4848
MOCK_INSUFFICIENT_ROLE_TOKEN = "test-supervisor-token-low-role"
4949

50-
# The seven Supervisor-managed system services exposed at /<service>/logs.
50+
# The eight Supervisor-managed system services exposed at /<service>/logs.
5151
# Mirrors SYSTEM_SERVICE_SLUGS in src/ha_mcp/tools/tools_utility.py.
5252
SYSTEM_SERVICES = frozenset(
53-
{"supervisor", "host", "core", "dns", "audio", "multicast", "observer"}
53+
{"supervisor", "host", "core", "dns", "audio", "cli", "multicast", "observer"}
5454
)
5555

5656
_SERVICE_LOGS_RE = re.compile(r"^/([a-z]+)/logs$")

0 commit comments

Comments
 (0)