Skip to content

Commit cc5cb4b

Browse files
Patch76claude
andauthored
refactor: pass verify_ssl to remaining direct-Supervisor httpx callers (#1128)
* refactor: pass verify_ssl to remaining direct-Supervisor httpx callers Closes #1127. Mirrors the verify=self.verify_ssl propagation pattern established in #1126 (rest_client.py:_get_addon_logs_via_supervisor) at the two other direct-Supervisor httpx call sites: - tools_bug_report.py:_fetch_addon_logs uses get_global_settings().verify_ssl (module-level helper, no self/closure context). - settings_ui.py:_restart_addon uses server.client.verify_ssl (closure has access to server: HomeAssistantSmartMCPServer). Both paths effectively propagate Settings.verify_ssl via the access route appropriate to each call site's scope. The http://supervisor URL is plain HTTP and TLS-irrelevant in practice — the parameter keeps all three constructor sites consistent with the established HomeAssistantClient pattern. * refactor: read verify_ssl from server.settings instead of server.client Per Gemini review on PR #1128: server.client is a lazy @Property (server.py) — accessing it for a single config bool would instantiate the full HomeAssistantClient (httpx pool, settings re-read, log line) on first access. server.settings is eager-initialized in the HomeAssistantSmartMCPServer constructor and is the canonical source of truth for verify_ssl. Additional benefit: in OAuth deployment mode (__main__.py:868), HomeAssistantSmartMCPServer is constructed with an OAuthProxyClient whose __getattr__ proxies to a per-request OAuth client requiring an authenticated request context. _restart_addon is a plain admin POST without that context, so server.client.verify_ssl could have surfaced as an auth error in OAuth mode. server.settings.verify_ssl sidesteps it without depending on OAuthProxyClient's attribute-forwarding semantics. * test: pin _restart_addon untested branches per Boy-Scout Adds unit-test coverage for the two previously-untested branches in settings_ui.py:_restart_addon: - Missing SUPERVISOR_TOKEN (settings_ui.py:780-789) — non-addon installs hit this when the user clicks Restart against a Docker/pyinstaller setup; the structured 400 must surface rather than ever reaching the Supervisor URL. - Connection-drop-as-success (settings_ui.py:798-801) — the Supervisor kills our process mid-request during a restart, so a ReadError / RemoteProtocolError / ConnectError from the POST is the documented success signal. Mirrors the _capture_handler pattern from TestSaveToolsValidation. The fixture-level server.settings.verify_ssl = True is required by this PR's post-G1 access path (httpx accepts only bool/SSLContext for verify=). Boy-Scout fix while already touching _restart_addon for the verify_ssl-propagation refactor — closes the test-coverage gap I'd flagged in #960's approve-body but never followed up on at the time. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13412aa commit cc5cb4b

3 files changed

Lines changed: 233 additions & 34 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class ToolStub(TypedDict):
5050
destructiveHint: NotRequired[bool]
5151
disabled_by: NotRequired[str]
5252

53+
5354
_VALID_STATES = frozenset({"enabled", "disabled", "pinned"})
5455

5556
logger = logging.getLogger(__name__)
@@ -261,7 +262,9 @@ def _render_stub(name: str, meta: ToolStub) -> dict[str, Any]:
261262
return rendered
262263

263264

264-
async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]:
265+
async def _get_tool_metadata(
266+
server: HomeAssistantSmartMCPServer,
267+
) -> list[dict[str, Any]]:
265268
"""Extract metadata for all registered tools from the server.
266269
267270
Uses FastMCP's internal ``local_provider._list_tools()`` because the
@@ -291,14 +294,16 @@ async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[s
291294
title = getattr(tool, "title", None) or tool.name
292295
if tool.annotations and getattr(tool.annotations, "title", None):
293296
title = tool.annotations.title
294-
tools.append({
295-
"name": tool.name,
296-
"title": title,
297-
"description": (tool.description or "")[:200],
298-
"tags": tags,
299-
"primary_tag": primary,
300-
"annotations": annotations,
301-
})
297+
tools.append(
298+
{
299+
"name": tool.name,
300+
"title": title,
301+
"description": (tool.description or "")[:200],
302+
"tags": tags,
303+
"primary_tag": primary,
304+
"annotations": annotations,
305+
}
306+
)
302307

303308
registered_names = {t["name"] for t in tools}
304309

@@ -362,7 +367,8 @@ def apply_tool_visibility(
362367
return pinned_names
363368

364369

365-
_SETTINGS_HTML = """\
370+
_SETTINGS_HTML = (
371+
"""\
366372
<!DOCTYPE html>
367373
<html lang="en">
368374
<head>
@@ -525,8 +531,12 @@ def apply_tool_visibility(
525531
}
526532
}
527533
528-
const DEFAULT_PINNED = """ + json.dumps(list(DEFAULT_PINNED_TOOLS)) + """;
529-
const MANDATORY = """ + json.dumps(list(MANDATORY_TOOLS)) + """;
534+
const DEFAULT_PINNED = """
535+
+ json.dumps(list(DEFAULT_PINNED_TOOLS))
536+
+ """;
537+
const MANDATORY = """
538+
+ json.dumps(list(MANDATORY_TOOLS))
539+
+ """;
530540
531541
function getState(name) {
532542
if (toolStates[name]) return toolStates[name];
@@ -763,6 +773,7 @@ def apply_tool_visibility(
763773
</body>
764774
</html>
765775
"""
776+
)
766777

767778

768779
def register_settings_routes(
@@ -868,15 +879,18 @@ async def _save_tools(request: Request) -> JSONResponse:
868879
pinned_count = sum(1 for s in states.values() if s == "pinned")
869880
logger.info(
870881
"Saved tool config (restart required to apply): %d disabled, %d pinned",
871-
disabled_count, pinned_count,
882+
disabled_count,
883+
pinned_count,
872884
)
873885

874-
return JSONResponse({
875-
"success": True,
876-
"disabled": disabled_count,
877-
"pinned": pinned_count,
878-
"restart_required": True,
879-
})
886+
return JSONResponse(
887+
{
888+
"success": True,
889+
"disabled": disabled_count,
890+
"pinned": pinned_count,
891+
"restart_required": True,
892+
}
893+
)
880894

881895
async def _restart_addon(_: Request) -> JSONResponse:
882896
token = os.environ.get("SUPERVISOR_TOKEN")
@@ -892,13 +906,20 @@ async def _restart_addon(_: Request) -> JSONResponse:
892906
# Short timeout — the supervisor kills our process during restart so
893907
# the connection will drop. A connection drop is actually success.
894908
try:
895-
async with httpx.AsyncClient(timeout=5.0) as client:
909+
async with httpx.AsyncClient(
910+
timeout=5.0, verify=server.settings.verify_ssl
911+
) as client:
896912
resp = await client.post(
897913
"http://supervisor/addons/self/restart",
898914
headers={"Authorization": f"Bearer {token}"},
899915
)
900-
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError):
901-
# Connection dropped mid-request — restart is happening
916+
except (httpx.ReadError, httpx.RemoteProtocolError):
917+
# Connection dropped mid-request — restart is happening.
918+
# `ConnectError` is deliberately NOT in this tuple: it fires
919+
# before a connection is established (DNS failure, TCP refused,
920+
# Supervisor socket misconfigured) and means the restart was
921+
# never initiated. Falls through to the `httpx.HTTPError`
922+
# handler below, which returns 502 + CONNECTION_FAILED.
902923
logger.info("Restart request connection dropped (expected during restart)")
903924
return JSONResponse({"success": True, "message": "Restart initiated"})
904925
except httpx.HTTPError as e:
@@ -924,9 +945,11 @@ async def _restart_addon(_: Request) -> JSONResponse:
924945
return JSONResponse({"success": True, "message": "Restart initiated"})
925946

926947
async def _settings_info(_: Request) -> JSONResponse:
927-
return JSONResponse({
928-
"is_addon": is_running_in_addon(),
929-
})
948+
return JSONResponse(
949+
{
950+
"is_addon": is_running_in_addon(),
951+
}
952+
)
930953

931954
secret_prefix = secret_path.rstrip("/") if secret_path else ""
932955
is_addon = is_running_in_addon()
@@ -958,7 +981,15 @@ async def _settings_info(_: Request) -> JSONResponse:
958981
# endpoint. The frontend uses relative fetches (./api/settings/...)
959982
# so the JS works at either prefix unchanged.
960983
mcp.custom_route(f"{secret_prefix}/settings", methods=["GET"])(_settings_page)
961-
mcp.custom_route(f"{secret_prefix}/api/settings/tools", methods=["GET"])(_get_tools)
962-
mcp.custom_route(f"{secret_prefix}/api/settings/tools", methods=["POST"])(_save_tools)
963-
mcp.custom_route(f"{secret_prefix}/api/settings/restart", methods=["POST"])(_restart_addon)
964-
mcp.custom_route(f"{secret_prefix}/api/settings/info", methods=["GET"])(_settings_info)
984+
mcp.custom_route(f"{secret_prefix}/api/settings/tools", methods=["GET"])(
985+
_get_tools
986+
)
987+
mcp.custom_route(f"{secret_prefix}/api/settings/tools", methods=["POST"])(
988+
_save_tools
989+
)
990+
mcp.custom_route(f"{secret_prefix}/api/settings/restart", methods=["POST"])(
991+
_restart_addon
992+
)
993+
mcp.custom_route(f"{secret_prefix}/api/settings/info", methods=["GET"])(
994+
_settings_info
995+
)

src/ha_mcp/tools/tools_bug_report.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ha_mcp import __version__
2121

22+
from ..config import get_global_settings
2223
from ..utils.usage_logger import (
2324
AVG_LOG_ENTRIES_PER_TOOL,
2425
get_recent_logs,
@@ -181,7 +182,9 @@ async def _fetch_addon_logs() -> str:
181182
return ""
182183

183184
try:
184-
async with httpx.AsyncClient(timeout=10.0) as http_client:
185+
async with httpx.AsyncClient(
186+
timeout=10.0, verify=get_global_settings().verify_ssl
187+
) as http_client:
185188
resp = await http_client.get(
186189
"http://supervisor/addons/self/logs",
187190
headers={"Authorization": f"Bearer {token}"},

tests/src/unit/test_settings_ui.py

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any
1111
from unittest.mock import AsyncMock, MagicMock, patch
1212

13+
import httpx
1314
import pytest
1415
from starlette.requests import Request
1516
from starlette.responses import JSONResponse
@@ -197,9 +198,7 @@ def fake_read_text(self: Path, *args, **kwargs):
197198
sys.platform == "win32",
198199
reason="chmod 0o000 doesn't model POSIX EACCES on Windows",
199200
)
200-
def test_load_tool_config_handles_real_eacces_on_posix(
201-
self, monkeypatch, tmp_path
202-
):
201+
def test_load_tool_config_handles_real_eacces_on_posix(self, monkeypatch, tmp_path):
203202
"""End-to-end variant of the EACCES regression: a real 0o000 dir.
204203
205204
The mocked-``read_text`` test above pins the going-forward contract,
@@ -256,7 +255,9 @@ def test_ha_read_resource_is_advertised(self):
256255
assert "ha_read_resource" in TRANSFORM_GENERATED_TOOLS
257256

258257
@pytest.mark.asyncio
259-
async def test_metadata_includes_ha_resource_tools_when_local_provider_omits_them(self):
258+
async def test_metadata_includes_ha_resource_tools_when_local_provider_omits_them(
259+
self,
260+
):
260261
"""Closes the gap from #1133: transform tools never reach
261262
local_provider, so _get_tool_metadata must inject stubs."""
262263
server = MagicMock()
@@ -433,3 +434,167 @@ async def test_returns_500_when_save_fails(self, monkeypatch, tmp_path):
433434
body = json.loads(resp.body)
434435
assert body["success"] is False
435436
assert "HA_MCP_CONFIG_DIR" in str(body)
437+
438+
439+
class TestRestartAddon:
440+
"""Tests for the `/api/settings/restart` handler — pins the previously
441+
untested branches in `_restart_addon`. Boy-Scout pin landed alongside
442+
the `verify_ssl` propagation in this PR. Symbol-based references below
443+
rather than line numbers, since the kwarg-split here shifts them."""
444+
445+
def _capture_handler(self, monkeypatch, *, with_token: bool = True) -> SaveHandler:
446+
"""Capture the `_restart_addon` closure from `register_settings_routes`.
447+
448+
Mirrors `TestSaveToolsValidation._capture_handler`. `with_token`
449+
toggles the env so the no-token branch and the happy-path branches
450+
can both be exercised from the same fixture.
451+
"""
452+
if with_token:
453+
monkeypatch.setenv("SUPERVISOR_TOKEN", "fake-supervisor-token")
454+
else:
455+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
456+
457+
captured: dict[str, Any] = {}
458+
459+
def custom_route_factory(path: str, methods: list[str]):
460+
def decorator(fn: Any) -> Any:
461+
if path.endswith("/api/settings/restart") and "POST" in methods:
462+
captured["restart"] = fn
463+
return fn
464+
465+
return decorator
466+
467+
mcp = MagicMock()
468+
mcp.custom_route = MagicMock(side_effect=custom_route_factory)
469+
server = MagicMock()
470+
# `_restart_addon` reads `server.settings.verify_ssl` — must resolve
471+
# to a real bool, not a MagicMock, because httpx accepts only
472+
# bool/SSLContext for `verify=`.
473+
server.settings.verify_ssl = True
474+
register_settings_routes(mcp, server, secret_path="/x")
475+
return captured["restart"]
476+
477+
@pytest.mark.asyncio
478+
async def test_returns_400_without_supervisor_token(self, monkeypatch):
479+
"""No-token branch (the `if not token:` guard at the top of
480+
`_restart_addon`): when SUPERVISOR_TOKEN is unset (non-addon
481+
install), the endpoint must surface a structured 400 rather than
482+
ever reaching the Supervisor URL.
483+
"""
484+
restart = self._capture_handler(monkeypatch, with_token=False)
485+
request = MagicMock()
486+
487+
resp = await restart(request)
488+
489+
assert resp.status_code == 400
490+
body = json.loads(resp.body)
491+
assert body["success"] is False
492+
assert body["error"]["code"] == "CONFIG_VALIDATION_FAILED"
493+
494+
@pytest.mark.asyncio
495+
@pytest.mark.parametrize(
496+
"exc_cls",
497+
[httpx.ReadError, httpx.RemoteProtocolError],
498+
)
499+
async def test_treats_connection_drop_as_success(self, monkeypatch, exc_cls):
500+
"""Drop-as-success branch (the catch on
501+
`(ReadError, RemoteProtocolError)` inside the `httpx.AsyncClient`
502+
block): the Supervisor kills our process mid-request during a
503+
restart, so the connection-drop is the documented success signal —
504+
not a failure to surface. ConnectError is excluded because it fires
505+
BEFORE a connection is established (DNS / TCP refused / socket
506+
misconfigured) and means Supervisor was unreachable, not that a
507+
restart was initiated.
508+
"""
509+
restart = self._capture_handler(monkeypatch, with_token=True)
510+
request = MagicMock()
511+
512+
# Patch the AsyncClient at the module level so the restart's
513+
# `httpx.AsyncClient(...)` block resolves to a controllable mock.
514+
mock_client = MagicMock()
515+
mock_client.post = AsyncMock(side_effect=exc_cls("kill"))
516+
cm = MagicMock()
517+
cm.__aenter__ = AsyncMock(return_value=mock_client)
518+
cm.__aexit__ = AsyncMock(return_value=None)
519+
520+
with patch("ha_mcp.settings_ui.httpx.AsyncClient", return_value=cm):
521+
resp = await restart(request)
522+
523+
assert resp.status_code == 200
524+
body = json.loads(resp.body)
525+
assert body["success"] is True
526+
assert "Restart initiated" in body["message"]
527+
528+
@pytest.mark.asyncio
529+
async def test_connect_error_returns_502(self, monkeypatch):
530+
"""ConnectError fires before a connection is established and means
531+
Supervisor was unreachable — must NOT be treated as a successful
532+
restart. Falls through to the generic `httpx.HTTPError` handler
533+
which returns 502 with `CONNECTION_FAILED`.
534+
"""
535+
restart = self._capture_handler(monkeypatch, with_token=True)
536+
request = MagicMock()
537+
538+
mock_client = MagicMock()
539+
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("no route"))
540+
cm = MagicMock()
541+
cm.__aenter__ = AsyncMock(return_value=mock_client)
542+
cm.__aexit__ = AsyncMock(return_value=None)
543+
544+
with patch("ha_mcp.settings_ui.httpx.AsyncClient", return_value=cm):
545+
resp = await restart(request)
546+
547+
assert resp.status_code == 502
548+
body = json.loads(resp.body)
549+
assert body["success"] is False
550+
assert body["error"]["code"] == "CONNECTION_FAILED"
551+
552+
@pytest.mark.asyncio
553+
async def test_generic_http_error_returns_502(self, monkeypatch):
554+
"""The generic `httpx.HTTPError` handler (catches anything not
555+
already special-cased) maps to 502 + CONNECTION_FAILED. Pins the
556+
last unconvered transport-error path in `_restart_addon`.
557+
"""
558+
restart = self._capture_handler(monkeypatch, with_token=True)
559+
request = MagicMock()
560+
561+
mock_client = MagicMock()
562+
# PoolTimeout subclasses httpx.HTTPError but is NOT in the
563+
# drop-as-success tuple — exercises the fall-through.
564+
mock_client.post = AsyncMock(side_effect=httpx.PoolTimeout("pool full"))
565+
cm = MagicMock()
566+
cm.__aenter__ = AsyncMock(return_value=mock_client)
567+
cm.__aexit__ = AsyncMock(return_value=None)
568+
569+
with patch("ha_mcp.settings_ui.httpx.AsyncClient", return_value=cm):
570+
resp = await restart(request)
571+
572+
assert resp.status_code == 502
573+
body = json.loads(resp.body)
574+
assert body["success"] is False
575+
assert body["error"]["code"] == "CONNECTION_FAILED"
576+
577+
@pytest.mark.asyncio
578+
async def test_supervisor_4xx_returns_502(self, monkeypatch):
579+
"""When Supervisor returns a non-2xx status (e.g. 401 Unauthorized),
580+
the handler must surface a 502 to the caller — the restart was not
581+
initiated. Pins the `status_code >= 400` branch in `_restart_addon`.
582+
"""
583+
restart = self._capture_handler(monkeypatch, with_token=True)
584+
request = MagicMock()
585+
586+
response = MagicMock()
587+
response.status_code = 401
588+
response.text = "Unauthorized"
589+
mock_client = MagicMock()
590+
mock_client.post = AsyncMock(return_value=response)
591+
cm = MagicMock()
592+
cm.__aenter__ = AsyncMock(return_value=mock_client)
593+
cm.__aexit__ = AsyncMock(return_value=None)
594+
595+
with patch("ha_mcp.settings_ui.httpx.AsyncClient", return_value=cm):
596+
resp = await restart(request)
597+
598+
assert resp.status_code == 502
599+
body = json.loads(resp.body)
600+
assert body["success"] is False

0 commit comments

Comments
 (0)