Skip to content

Commit 93549d1

Browse files
committed
fix: route addon log fetches directly to supervisor on addon installs
Addresses homeassistant-ai#1116. The HA Core proxy at /api/hassio/addons/{slug}/logs returns 403 against SUPERVISOR_TOKEN on current HA Core releases. Route around HA Core entirely on add-on installs by hitting the Supervisor REST API at http://supervisor/addons/{slug}/logs directly — same pattern tools_bug_report.py already uses for self-logs. The HA-Core-proxy path stays as the fallback for non-addon installs (Docker/pip with admin LLA). Branching gate is the existing is_running_in_addon() helper in _version.py — no fourth duplicate of the SUPERVISOR_TOKEN env-var check. Test coverage pins both branches plus the gate selection — see new TestGetAddonLogsViaSupervisor and TestGetAddonLogsBranchSelection classes in test_tools_utility_supervisor_logs.py.
1 parent 6751d08 commit 93549d1

2 files changed

Lines changed: 303 additions & 9 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 68 additions & 8 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,86 @@ 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+
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
444+
``/api/hassio/addons/{slug}/logs`` rejects this token+path combination
445+
on current HA Core releases (see #1116) — the direct path bypasses
446+
HA Core entirely and is the documented Supervisor contract.
447+
448+
On non-addon installs (Docker, pyinstaller, pip pointing at a normal
449+
HA URL), falls back to the HA Core proxy path. That path requires an
450+
admin LLA but works fine when not invoked from the add-on container.
451+
452+
Both branches return ``text/plain`` log content.
443453
444454
Raises:
445-
HomeAssistantAuthError: 401 from HA Core.
455+
HomeAssistantAuthError: 401 response.
446456
HomeAssistantAPIError: Non-2xx response (e.g. 404 unknown slug,
447-
400 addon not installed). `status_code` is set so callers
457+
400 addon not installed). ``status_code`` is set so callers
448458
can map to specific suggestions.
449459
HomeAssistantConnectionError: Network, timeout, or transport error.
450460
"""
451-
logger.debug(f"Fetching addon logs for slug={slug}")
461+
if is_running_in_addon():
462+
return await self._get_addon_logs_via_supervisor(slug)
463+
464+
logger.debug(f"Fetching addon logs for slug={slug} via HA Core proxy")
452465
response = await self._raw_request(
453466
"GET",
454467
f"/hassio/addons/{slug}/logs",
455468
headers={"Accept": "text/plain"},
456469
)
457470
return response.text
458471

472+
async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
473+
"""Direct Supervisor REST API fetch for add-on installs.
474+
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.
480+
"""
481+
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")
484+
485+
try:
486+
async with httpx.AsyncClient(
487+
timeout=httpx.Timeout(self.timeout)
488+
) as client:
489+
response = await client.get(
490+
url,
491+
headers={
492+
"Authorization": f"Bearer {token}",
493+
"Accept": "text/plain",
494+
},
495+
)
496+
except httpx.TimeoutException as e:
497+
raise HomeAssistantConnectionError(
498+
f"Request timeout fetching addon logs: {e}"
499+
) from e
500+
except httpx.HTTPError as e:
501+
raise HomeAssistantConnectionError(
502+
f"HTTP error fetching addon logs: {e}"
503+
) from e
504+
505+
if response.status_code == 401:
506+
raise HomeAssistantAuthError(
507+
"Invalid Supervisor token for /addons/<slug>/logs"
508+
)
509+
if response.status_code >= 400:
510+
text_body = response.text
511+
message = text_body.strip() or response.reason_phrase or "<empty body>"
512+
raise HomeAssistantAPIError(
513+
f"API error: {response.status_code} - {message}",
514+
status_code=response.status_code,
515+
response_data={"message": text_body},
516+
)
517+
return response.text
518+
459519
async def test_connection(self) -> tuple[bool, str | None]:
460520
"""
461521
Test connection to Home Assistant.

tests/src/unit/test_tools_utility_supervisor_logs.py

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ def mock_client():
4343
return client
4444

4545

46+
@pytest.fixture
47+
def non_addon_install():
48+
"""Force `is_running_in_addon()` False so `get_addon_logs` takes the
49+
HA-Core-proxy fallback path. Required for tests that mock
50+
`httpx_client.request` — the Supervisor-direct branch uses a fresh
51+
`httpx.AsyncClient` and would bypass that mock entirely.
52+
"""
53+
with patch(
54+
"ha_mcp.client.rest_client.is_running_in_addon", return_value=False
55+
):
56+
yield
57+
58+
59+
@pytest.fixture
60+
def addon_install():
61+
"""Force `is_running_in_addon()` True and stub `SUPERVISOR_TOKEN` so
62+
`get_addon_logs` takes the direct Supervisor REST API path."""
63+
with (
64+
patch(
65+
"ha_mcp.client.rest_client.is_running_in_addon", return_value=True
66+
),
67+
patch.dict("os.environ", {"SUPERVISOR_TOKEN": "supervisor-token-test"}),
68+
):
69+
yield
70+
71+
4672
def _register_and_collect(client: Any) -> dict[str, Any]:
4773
"""Register utility tools on a collector mcp and return the registered tools.
4874
@@ -69,7 +95,19 @@ def _parse_tool_error(exc_info: pytest.ExceptionInfo[ToolError]) -> dict[str, An
6995

7096

7197
class TestGetAddonLogs:
72-
"""Tests for the REST-client `get_addon_logs` method (the core fix)."""
98+
"""Tests for the REST-client `get_addon_logs` method on non-addon installs.
99+
100+
These exercise the HA-Core-proxy fallback branch (`/hassio/addons/{slug}/logs`)
101+
via `httpx_client.request`. The `non_addon_install` fixture forces
102+
`is_running_in_addon()` False so `get_addon_logs` doesn't take the
103+
Supervisor-direct branch — that path opens a fresh `httpx.AsyncClient`
104+
and would bypass the `mock_client.httpx_client` mock entirely.
105+
"""
106+
107+
@pytest.fixture(autouse=True)
108+
def _force_non_addon(self, non_addon_install):
109+
"""Apply `non_addon_install` to every test in this class."""
110+
yield
73111

74112
@pytest.mark.asyncio
75113
async def test_returns_text_on_200(self, mock_client):
@@ -167,6 +205,202 @@ async def test_does_not_parse_json(self, mock_client):
167205
mock_response.json.assert_not_called()
168206

169207

208+
class TestGetAddonLogsViaSupervisor:
209+
"""Tests for the Supervisor-direct branch of `get_addon_logs`.
210+
211+
Regression coverage for #1116: on add-on installs, the HA-Core-proxy path
212+
`/api/hassio/addons/{slug}/logs` returns 403 for every slug because HA Core
213+
rejects the Supervisor-token+route combination. The fix routes around HA
214+
Core entirely on add-on installs by hitting the documented Supervisor REST
215+
API at `http://supervisor/addons/{slug}/logs` with the Supervisor token.
216+
"""
217+
218+
@pytest.fixture
219+
def mock_async_client_class(self):
220+
"""Patch `httpx.AsyncClient` (the class) inside `rest_client` so the
221+
``async with httpx.AsyncClient(...) as client:`` block returns a
222+
controllable mock client. Yields the inner client mock so each test
223+
can configure ``.get`` directly.
224+
"""
225+
inner_client = MagicMock()
226+
inner_client.get = AsyncMock()
227+
228+
cm = MagicMock()
229+
cm.__aenter__ = AsyncMock(return_value=inner_client)
230+
cm.__aexit__ = AsyncMock(return_value=None)
231+
232+
client_class = MagicMock(return_value=cm)
233+
with patch(
234+
"ha_mcp.client.rest_client.httpx.AsyncClient", client_class
235+
):
236+
yield inner_client, client_class
237+
238+
@pytest.mark.asyncio
239+
async def test_uses_direct_supervisor_url_and_supervisor_token(
240+
self, mock_client, addon_install, mock_async_client_class
241+
):
242+
"""The fix's primary contract: on add-on installs, the request must go
243+
to `http://supervisor/addons/{slug}/logs` with `Authorization: Bearer
244+
${SUPERVISOR_TOKEN}` — NOT through HA Core's `/api/hassio/...` proxy
245+
with the user's HA token.
246+
"""
247+
inner_client, _ = mock_async_client_class
248+
mock_response = MagicMock()
249+
mock_response.status_code = 200
250+
mock_response.text = "addon log line 1\nready\n"
251+
inner_client.get.return_value = mock_response
252+
253+
result = await mock_client.get_addon_logs("81f33d0f_ha_mcp")
254+
255+
assert "addon log line 1" in result
256+
inner_client.get.assert_awaited_once()
257+
args, kwargs = inner_client.get.call_args
258+
assert args[0] == "http://supervisor/addons/81f33d0f_ha_mcp/logs"
259+
assert (
260+
kwargs["headers"]["Authorization"] == "Bearer supervisor-token-test"
261+
)
262+
assert kwargs["headers"]["Accept"] == "text/plain"
263+
# Critically: the HA-Core-proxy path must NOT have been touched.
264+
mock_client.httpx_client.request.assert_not_called()
265+
266+
@pytest.mark.asyncio
267+
async def test_raises_auth_error_on_401(
268+
self, mock_client, addon_install, mock_async_client_class
269+
):
270+
"""401 from Supervisor (bad/expired token) maps to HomeAssistantAuthError
271+
the same way the HA-Core-proxy path does — so error mapping in the
272+
wrapper layer stays uniform regardless of which branch ran.
273+
"""
274+
inner_client, _ = mock_async_client_class
275+
mock_response = MagicMock()
276+
mock_response.status_code = 401
277+
mock_response.text = "unauthorized"
278+
inner_client.get.return_value = mock_response
279+
280+
with pytest.raises(HomeAssistantAuthError):
281+
await mock_client.get_addon_logs("core_mosquitto")
282+
283+
@pytest.mark.asyncio
284+
async def test_raises_api_error_on_404(
285+
self, mock_client, addon_install, mock_async_client_class
286+
):
287+
"""404 (unknown slug) propagates as HomeAssistantAPIError with the
288+
Supervisor's plain-text body in the message — preserves the same
289+
error shape callers expect from the HA-Core-proxy branch."""
290+
inner_client, _ = mock_async_client_class
291+
mock_response = MagicMock()
292+
mock_response.status_code = 404
293+
mock_response.text = "Addon is not installed"
294+
mock_response.reason_phrase = "Not Found"
295+
inner_client.get.return_value = mock_response
296+
297+
with pytest.raises(HomeAssistantAPIError) as exc_info:
298+
await mock_client.get_addon_logs("nonexistent_slug")
299+
300+
assert exc_info.value.status_code == 404
301+
assert "Addon is not installed" in str(exc_info.value)
302+
303+
@pytest.mark.asyncio
304+
async def test_empty_body_falls_back_to_reason_phrase(
305+
self, mock_client, addon_install, mock_async_client_class
306+
):
307+
"""If Supervisor returns a non-2xx with empty body, the error tail
308+
falls back to the HTTP reason phrase rather than landing as
309+
``"API error: 5xx - "`` with a blank tail."""
310+
inner_client, _ = mock_async_client_class
311+
mock_response = MagicMock()
312+
mock_response.status_code = 502
313+
mock_response.text = ""
314+
mock_response.reason_phrase = "Bad Gateway"
315+
inner_client.get.return_value = mock_response
316+
317+
with pytest.raises(HomeAssistantAPIError) as exc_info:
318+
await mock_client.get_addon_logs("core_mosquitto")
319+
320+
assert exc_info.value.status_code == 502
321+
assert "Bad Gateway" in str(exc_info.value)
322+
assert not str(exc_info.value).endswith(" - ")
323+
324+
@pytest.mark.asyncio
325+
async def test_raises_connection_error_on_timeout(
326+
self, mock_client, addon_install, mock_async_client_class
327+
):
328+
inner_client, _ = mock_async_client_class
329+
inner_client.get.side_effect = httpx.TimeoutException("supervisor timeout")
330+
331+
with pytest.raises(HomeAssistantConnectionError):
332+
await mock_client.get_addon_logs("core_mosquitto")
333+
334+
@pytest.mark.asyncio
335+
async def test_raises_connection_error_on_network_failure(
336+
self, mock_client, addon_install, mock_async_client_class
337+
):
338+
inner_client, _ = mock_async_client_class
339+
inner_client.get.side_effect = httpx.ConnectError("supervisor unreachable")
340+
341+
with pytest.raises(HomeAssistantConnectionError):
342+
await mock_client.get_addon_logs("core_mosquitto")
343+
344+
345+
class TestGetAddonLogsBranchSelection:
346+
"""The branch decision is made via `is_running_in_addon()`. Pin both
347+
directions so a future refactor of the gate (e.g. inlining the env-var
348+
check) doesn't silently regress one branch.
349+
"""
350+
351+
@pytest.mark.asyncio
352+
async def test_non_addon_install_uses_ha_core_proxy(self, mock_client):
353+
"""`is_running_in_addon()` False → HA-Core-proxy path, no Supervisor URL."""
354+
mock_response = MagicMock()
355+
mock_response.status_code = 200
356+
mock_response.text = "via proxy\n"
357+
mock_client.httpx_client.request = AsyncMock(return_value=mock_response)
358+
359+
with patch(
360+
"ha_mcp.client.rest_client.is_running_in_addon", return_value=False
361+
):
362+
result = await mock_client.get_addon_logs("core_mosquitto")
363+
364+
assert "via proxy" in result
365+
mock_client.httpx_client.request.assert_called_once()
366+
args, _ = mock_client.httpx_client.request.call_args
367+
assert args[1] == "/hassio/addons/core_mosquitto/logs"
368+
369+
@pytest.mark.asyncio
370+
async def test_addon_install_uses_supervisor_direct(self, mock_client):
371+
"""`is_running_in_addon()` True → Supervisor-direct path, no HA-Core call."""
372+
inner_client = MagicMock()
373+
inner_client.get = AsyncMock()
374+
mock_response = MagicMock()
375+
mock_response.status_code = 200
376+
mock_response.text = "via supervisor\n"
377+
inner_client.get.return_value = mock_response
378+
379+
cm = MagicMock()
380+
cm.__aenter__ = AsyncMock(return_value=inner_client)
381+
cm.__aexit__ = AsyncMock(return_value=None)
382+
383+
with (
384+
patch(
385+
"ha_mcp.client.rest_client.is_running_in_addon",
386+
return_value=True,
387+
),
388+
patch.dict(
389+
"os.environ", {"SUPERVISOR_TOKEN": "supervisor-token-branch"}
390+
),
391+
patch(
392+
"ha_mcp.client.rest_client.httpx.AsyncClient",
393+
return_value=cm,
394+
),
395+
):
396+
result = await mock_client.get_addon_logs("core_mosquitto")
397+
398+
assert "via supervisor" in result
399+
# HA-Core-proxy must NOT have been called.
400+
mock_client.httpx_client.request.assert_not_called()
401+
inner_client.get.assert_awaited_once()
402+
403+
170404
class TestRawRequestEmptyBodyFallback:
171405
"""Error message must stay actionable even when the 4xx body is empty.
172406

0 commit comments

Comments
 (0)