Skip to content

Commit 177dc2a

Browse files
Patch76claude
andcommitted
fix(addon): bound Core-relayed Supervisor failures too
The first bound sat in _supervisor_rest_failure, which only the direct REST transport passes through. A standard install routes through _supervisor_api_call_via_core, whose failed WebSocket result is returned unchanged, so Supervisor text relayed by Core still reached the model unbounded. Move the bound to _raise_supervisor_api_failure, the single point every non-retryable failure of either transport passes, and cover the Core route with a regression test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1bcb8a6 commit 177dc2a

2 files changed

Lines changed: 39 additions & 8 deletions

File tree

src/ha_mcp/tools/tools_addons.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,7 @@ def _supervisor_rest_failure(
284284
response_data: dict[str, Any] | None = None,
285285
) -> dict[str, Any]:
286286
"""Normalize direct REST failure and retain 4xx/5xx status metadata."""
287-
# ``error`` is built from the Supervisor payload, so it is only as bounded
288-
# as whatever Supervisor sent; it reaches the model as the tool error.
289-
error_text = _bounded_supervisor_text(str(error))
287+
error_text = str(error)
290288
result: dict[str, Any] = {"success": False, "error": error_text}
291289
if response.is_error:
292290
result["_status_code"] = response.status_code
@@ -564,7 +562,12 @@ def _raise_supervisor_api_failure(
564562
endpoint: str,
565563
) -> NoReturn:
566564
"""Raise the structured exception represented by a non-retryable result."""
567-
error_text = str(result.get("error", f"Supervisor API call failed: {endpoint}"))
565+
# Both transports land here, and both carry Supervisor's own text: the
566+
# direct REST payload and the message Core relays over the WebSocket
567+
# bridge. Bind the size once, where every failure passes.
568+
error_text = _bounded_supervisor_text(
569+
str(result.get("error", f"Supervisor API call failed: {endpoint}"))
570+
)
568571
status_code = result.get("_status_code")
569572
response_data = result.get("_response_data")
570573
if status_code == 401:

tests/src/unit/test_tools_addons.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4623,6 +4623,30 @@ async def test_addon_mode_invalid_json_error_caps_response_body(self, monkeypatc
46234623
"x" * (50 * 1024 - len(suffix)) + suffix
46244624
)
46254625

4626+
@pytest.mark.asyncio
4627+
async def test_core_routed_failure_message_is_capped(self, monkeypatch):
4628+
"""A Core-relayed Supervisor failure cannot produce an unbounded error."""
4629+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4630+
4631+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
4632+
client = _make_mock_client()
4633+
client.send_websocket_message = AsyncMock(
4634+
return_value={
4635+
"success": False,
4636+
"error": "Command failed: " + "w" * (50 * 1024 + 1),
4637+
}
4638+
)
4639+
4640+
with pytest.raises(ToolError) as exc_info:
4641+
await _supervisor_api_call(client, "/addons")
4642+
4643+
payload = _parse_tool_error(exc_info)
4644+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4645+
message = payload["error"]["message"]
4646+
assert message.startswith("Command failed: w")
4647+
assert message.endswith(suffix)
4648+
assert len(message) == 50 * 1024
4649+
46264650
@pytest.mark.asyncio
46274651
async def test_addon_mode_non_object_json_caps_error_message(self, monkeypatch):
46284652
"""A valid-JSON non-object body cannot produce an unbounded tool error."""
@@ -4656,7 +4680,7 @@ async def test_addon_mode_non_object_json_caps_error_message(self, monkeypatch):
46564680
suffix = "\n[Supervisor response truncated to 50 KiB]"
46574681
assert message.startswith(prefix)
46584682
assert message.endswith(suffix)
4659-
assert len(message) == len("Command failed: ") + 50 * 1024
4683+
assert len(message) == 50 * 1024
46604684

46614685
@pytest.mark.asyncio
46624686
async def test_addon_mode_error_payload_message_is_capped(self, monkeypatch):
@@ -4687,9 +4711,13 @@ async def test_addon_mode_error_payload_message_is_capped(self, monkeypatch):
46874711

46884712
payload = _parse_tool_error(exc_info)
46894713
suffix = "\n[Supervisor response truncated to 50 KiB]"
4690-
assert payload["error"]["message"] == (
4691-
"Command failed: " + "y" * (50 * 1024 - len(suffix)) + suffix
4692-
)
4714+
message = payload["error"]["message"]
4715+
# The status-code path prefixes the bounded Supervisor text, so the
4716+
# constant framing sits outside the bound.
4717+
prefix = "Command failed: "
4718+
assert message.startswith(prefix + "y")
4719+
assert message.endswith(suffix)
4720+
assert len(message.removeprefix(prefix)) == 50 * 1024
46934721

46944722
@pytest.mark.asyncio
46954723
async def test_addon_mode_non_mapping_result_payload_is_capped(self, monkeypatch):

0 commit comments

Comments
 (0)