Skip to content

Commit 4463d7d

Browse files
committed
fix: address KP13 CR — role bump, fail-fast token, system_service source
Critical (review items 1-4): - Empty SUPERVISOR_TOKEN now fail-fast HomeAssistantAuthError with distinct message ("absent at call time"), so detection/config mismatches don't read as "token rejected" (item 1). - 403 carved out as distinct branch with role-hint suggestion + warning log before raise — most-likely cause for homeassistant-ai#1116-class failures is now hassio_role too low (item 2). - _get_supervisor_log docstring updated to describe the branch-on- is_running_in_addon() behavior, drop homeassistant-ai#950-only framing (item 3). - _get_addon_logs_via_supervisor docstring rewritten to enumerate scope/role differences vs _fetch_addon_logs instead of claiming mirror (item 4). Important (items 5-9): - verify=self.verify_ssl kept with explicit symmetry comment citing homeassistant-ai#1128's three-site convention (item 5, KP13's option B). - Supervisor's {"result":"error","message":"..."} JSON envelope now parsed before the raw-text fallback (item 6). - Distinct timeout vs transport error messages (item 7). - logger.warning fires before every 4xx raise so operators see status+path without enabling debug (item 9). Test gaps (items 10-15): - New: test_raises_auth_error_on_empty_supervisor_token (item 10). - New: test_raises_connection_error_on_remote_protocol_error (item 11). - New: test_empty_body_no_reason_phrase_uses_placeholder for tier-3 fallback parity with the proxy branch (item 12). - ctor-kwargs (verify + timeout) asserted on the URL+auth happy path so a regression that hard-codes either keeps CI red (item 13). - TestGetAddonLogsBranchSelection::test_addon_install_uses_supervisor_direct shrunk to gate-only — URL/auth contract delegated to dedicated class (item 14). - patch path migrated from "ha_mcp.client.rest_client.httpx.AsyncClient" to "httpx.AsyncClient" — robust to both \`import httpx\` and a future \`from httpx import AsyncClient\` (item 15). Comment trims (items 16-17): - get_addon_logs docstring gate description corrected to is_running_in_addon() rather than "SUPERVISOR_TOKEN env present" (item 16). - Per-test/fixture/class-level redundant narration trimmed (item 17). Decline: - Item 8 (level applies to source="supervisor"): warning is already emitted at tools_utility.py:139 — \`supervisor\` is in the level-not-applicable warning tuple, pinned by test_level_param_emits_warning_for_supervisor_source. This PR adds \`system_service\` to the same tuple plus a parallel test for parity. Scope additions per KP13's PR-comment 12:42: - hassio_role: default → manager in both addon config.yamls, with comment citing the live test result. - New source="system_service" with slug enum {supervisor, host, core, dns, audio, multicast, observer}. Hits http://supervisor/<service>/logs via the same direct-Supervisor pattern (extracted to shared _supervisor_logs_get helper). Caller-layer slug-enum validation gives users the allowed-set before the request fires.
1 parent 9e6c012 commit 4463d7d

5 files changed

Lines changed: 686 additions & 103 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: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -438,9 +438,10 @@ async def get_error_log(self) -> str:
438438
async def get_addon_logs(self, slug: str) -> str:
439439
"""Fetch an add-on's container logs.
440440
441-
On add-on installs (``SUPERVISOR_TOKEN`` env present), goes directly to
442-
the Supervisor REST API at ``http://supervisor/addons/{slug}/logs``
443-
with the Supervisor token. The HA Core proxy at
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
444445
``/api/hassio/addons/{slug}/logs`` rejects this token+path combination
445446
on current HA Core releases (see #1116) — the direct path bypasses
446447
HA Core entirely and is the documented Supervisor contract.
@@ -452,10 +453,12 @@ async def get_addon_logs(self, slug: str) -> str:
452453
Both branches return ``text/plain`` log content.
453454
454455
Raises:
455-
HomeAssistantAuthError: 401 response.
456-
HomeAssistantAPIError: Non-2xx response (e.g. 404 unknown slug,
457-
400 addon not installed). ``status_code`` is set so callers
458-
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.
459462
HomeAssistantConnectionError: Network, timeout, or transport error.
460463
"""
461464
if is_running_in_addon():
@@ -469,22 +472,51 @@ async def get_addon_logs(self, slug: str) -> str:
469472
)
470473
return response.text
471474

472-
async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
473-
"""Fetch add-on logs directly from the Supervisor REST API.
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
474483
475-
Mirrors the access pattern used by ``tools_bug_report._fetch_addon_logs``:
476-
a fresh ``httpx.AsyncClient`` against ``http://supervisor`` authed with
477-
the Supervisor token. Bypasses ``HomeAssistantClient.httpx_client``
478-
because the Supervisor endpoint takes a different base URL and a
479-
different token than the HA Core REST API.
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.
480499
"""
481500
token = os.environ.get("SUPERVISOR_TOKEN", "")
482-
url = f"http://supervisor/addons/{slug}/logs"
483-
logger.debug(f"Fetching addon logs for slug={slug} via Supervisor direct")
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)
484513

485514
try:
486515
async with httpx.AsyncClient(
487516
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).
488520
verify=self.verify_ssl,
489521
) as client:
490522
response = await client.get(
@@ -496,27 +528,92 @@ async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
496528
)
497529
except httpx.TimeoutException as e:
498530
raise HomeAssistantConnectionError(
499-
f"Request timeout fetching addon logs: {e}"
531+
f"Timeout fetching /{path}/logs from Supervisor: {e}"
500532
) from e
501533
except httpx.HTTPError as e:
502534
raise HomeAssistantConnectionError(
503-
f"HTTP error fetching addon logs: {e}"
535+
f"Transport error fetching /{path}/logs from Supervisor: {e}"
504536
) from e
505537

506538
if response.status_code == 401:
507539
raise HomeAssistantAuthError(
508-
"Invalid Supervisor token for /addons/<slug>/logs"
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},
509557
)
510558
if response.status_code >= 400:
511559
text_body = response.text
512-
message = text_body.strip() or response.reason_phrase or "<empty body>"
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+
)
513581
raise HomeAssistantAPIError(
514582
f"API error: {response.status_code} - {message}",
515583
status_code=response.status_code,
516-
response_data={"message": text_body},
584+
response_data={"message": text_body, "path": path},
517585
)
518586
return response.text
519587

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+
520617
async def test_connection(self) -> tuple[bool, str | None]:
521618
"""
522619
Test connection to Home Assistant.

0 commit comments

Comments
 (0)