Skip to content

Commit adb39ea

Browse files
Patch76claude
andauthored
fix(addon): bound Supervisor error payloads reaching the model (#2280)
* fix(addon): bound Supervisor error payloads reaching the model The direct Supervisor REST path builds its tool errors out of parsed response payloads: the two invalid-payload branches embed the payload repr, the failure branch forwards Supervisor's own message, and the result mapping embeds a non-mapping result payload. None of those were bounded, so one oversized Supervisor response could produce a tool error many times larger than the 50 KiB bound the raw-text paths already apply. Route them through that same bound. The helper now takes a string, so the response-body path and the payload-derived messages share one implementation and one truncation marker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 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> * fix(addon): bound the job-collision give-up message too Moving the bound to _raise_supervisor_api_failure left one reporting path uncovered: when a job-group collision exhausts the retry budget, _supervisor_api_call raises its own error from the raw error_text it computed for the marker test, so an oversized collision message was still reported in full. That path was bounded before the move, on the direct REST transport, so this closes a gap the move opened. Bind the size after the marker classification is settled, and cover the give-up path with a regression test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2818668 commit adb39ea

2 files changed

Lines changed: 179 additions & 7 deletions

File tree

src/ha_mcp/tools/tools_addons.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,13 +316,17 @@ def _supervisor_invalid_response(
316316
return _supervisor_rest_failure(response, error)
317317

318318

319+
def _bounded_supervisor_text(value: str) -> str:
320+
"""Return model-safe Supervisor text, marking any truncation visibly."""
321+
if len(value) <= _MAX_RESPONSE_SIZE:
322+
return value
323+
prefix_size = _MAX_RESPONSE_SIZE - len(_SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX)
324+
return value[:prefix_size] + _SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX
325+
326+
319327
def _bounded_supervisor_response_text(response: httpx.Response) -> str:
320328
"""Return a model-safe Supervisor response body with visible truncation."""
321-
body = response.text.strip()
322-
if len(body) <= _MAX_RESPONSE_SIZE:
323-
return body
324-
prefix_size = _MAX_RESPONSE_SIZE - len(_SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX)
325-
return body[:prefix_size] + _SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX
329+
return _bounded_supervisor_text(response.text.strip())
326330

327331

328332
def _normalize_supervisor_rest_response(
@@ -558,7 +562,12 @@ def _raise_supervisor_api_failure(
558562
endpoint: str,
559563
) -> NoReturn:
560564
"""Raise the structured exception represented by a non-retryable result."""
561-
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+
)
562571
status_code = result.get("_status_code")
563572
response_data = result.get("_response_data")
564573
if status_code == 401:
@@ -613,7 +622,7 @@ def _supervisor_result_mapping(
613622
return payload
614623

615624
verb = method.upper()
616-
message = (
625+
message = _bounded_supervisor_text(
617626
f"Supervisor API {verb} {endpoint} returned an invalid result payload: "
618627
f"{payload!r}"
619628
)
@@ -717,6 +726,10 @@ async def _supervisor_api_call(
717726
if _JOB_COLLISION_MARKER not in error_text.lower():
718727
_raise_supervisor_api_failure(result, endpoint)
719728

729+
# The marker test runs on the raw text; everything below reports
730+
# it, so bind the size once the classification is settled.
731+
error_text = _bounded_supervisor_text(error_text)
732+
720733
remaining = deadline - time.monotonic()
721734
if remaining <= 0:
722735
# The retry budget is exhausted; the group may be stuck or

tests/src/unit/test_tools_addons.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4623,6 +4623,165 @@ 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_job_collision_giveup_message_is_capped(self, monkeypatch):
4628+
"""An exhausted job-collision retry cannot report an unbounded error."""
4629+
from ha_mcp.tools import tools_addons
4630+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4631+
4632+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
4633+
monkeypatch.setattr(tools_addons, "_JOB_COLLISION_RETRY_WINDOW", 0.0)
4634+
client = _make_mock_client()
4635+
client.send_websocket_message = AsyncMock(
4636+
return_value={
4637+
"success": False,
4638+
"error": (
4639+
"Command failed: another job is running for job group "
4640+
+ "v" * (50 * 1024 + 1)
4641+
),
4642+
}
4643+
)
4644+
4645+
with pytest.raises(ToolError) as exc_info:
4646+
await _supervisor_api_call(client, "/addons/x/restart", method="POST")
4647+
4648+
payload = _parse_tool_error(exc_info)
4649+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4650+
message = payload["error"]["message"]
4651+
assert message.endswith(suffix)
4652+
assert len(message) == 50 * 1024
4653+
4654+
@pytest.mark.asyncio
4655+
async def test_core_routed_failure_message_is_capped(self, monkeypatch):
4656+
"""A Core-relayed Supervisor failure cannot produce an unbounded error."""
4657+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4658+
4659+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
4660+
client = _make_mock_client()
4661+
client.send_websocket_message = AsyncMock(
4662+
return_value={
4663+
"success": False,
4664+
"error": "Command failed: " + "w" * (50 * 1024 + 1),
4665+
}
4666+
)
4667+
4668+
with pytest.raises(ToolError) as exc_info:
4669+
await _supervisor_api_call(client, "/addons")
4670+
4671+
payload = _parse_tool_error(exc_info)
4672+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4673+
message = payload["error"]["message"]
4674+
assert message.startswith("Command failed: w")
4675+
assert message.endswith(suffix)
4676+
assert len(message) == 50 * 1024
4677+
4678+
@pytest.mark.asyncio
4679+
async def test_addon_mode_non_object_json_caps_error_message(self, monkeypatch):
4680+
"""A valid-JSON non-object body cannot produce an unbounded tool error."""
4681+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4682+
4683+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4684+
client = _make_mock_client()
4685+
client.send_websocket_message = AsyncMock()
4686+
direct_client = AsyncMock()
4687+
direct_client.request.return_value = httpx.Response(
4688+
200,
4689+
json=["x" * 1000] * 200,
4690+
)
4691+
context = MagicMock()
4692+
context.__aenter__ = AsyncMock(return_value=direct_client)
4693+
context.__aexit__ = AsyncMock(return_value=False)
4694+
4695+
with (
4696+
patch(
4697+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4698+
return_value=context,
4699+
create=True,
4700+
),
4701+
pytest.raises(ToolError) as exc_info,
4702+
):
4703+
await _supervisor_api_call(client, "/addons")
4704+
4705+
payload = _parse_tool_error(exc_info)
4706+
message = payload["error"]["message"]
4707+
prefix = "Command failed: Supervisor returned an invalid response: "
4708+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4709+
assert message.startswith(prefix)
4710+
assert message.endswith(suffix)
4711+
assert len(message) == 50 * 1024
4712+
4713+
@pytest.mark.asyncio
4714+
async def test_addon_mode_error_payload_message_is_capped(self, monkeypatch):
4715+
"""An oversized Supervisor error message is bounded before it is raised."""
4716+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4717+
4718+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4719+
client = _make_mock_client()
4720+
client.send_websocket_message = AsyncMock()
4721+
direct_client = AsyncMock()
4722+
direct_client.request.return_value = httpx.Response(
4723+
502,
4724+
json={"result": "error", "message": "y" * (50 * 1024 + 1)},
4725+
)
4726+
context = MagicMock()
4727+
context.__aenter__ = AsyncMock(return_value=direct_client)
4728+
context.__aexit__ = AsyncMock(return_value=False)
4729+
4730+
with (
4731+
patch(
4732+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4733+
return_value=context,
4734+
create=True,
4735+
),
4736+
pytest.raises(ToolError) as exc_info,
4737+
):
4738+
await _supervisor_api_call(client, "/addons")
4739+
4740+
payload = _parse_tool_error(exc_info)
4741+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4742+
message = payload["error"]["message"]
4743+
# The status-code path prefixes the bounded Supervisor text, so the
4744+
# constant framing sits outside the bound.
4745+
prefix = "Command failed: "
4746+
assert message.startswith(prefix + "y")
4747+
assert message.endswith(suffix)
4748+
assert len(message.removeprefix(prefix)) == 50 * 1024
4749+
4750+
@pytest.mark.asyncio
4751+
async def test_addon_mode_non_mapping_result_payload_is_capped(self, monkeypatch):
4752+
"""An oversized non-mapping result payload is bounded before it is raised."""
4753+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4754+
4755+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4756+
client = _make_mock_client()
4757+
client.send_websocket_message = AsyncMock()
4758+
direct_client = AsyncMock()
4759+
direct_client.request.return_value = httpx.Response(
4760+
200,
4761+
json={"result": "ok", "data": "z" * (50 * 1024 + 1)},
4762+
)
4763+
context = MagicMock()
4764+
context.__aenter__ = AsyncMock(return_value=direct_client)
4765+
context.__aexit__ = AsyncMock(return_value=False)
4766+
4767+
with (
4768+
patch(
4769+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4770+
return_value=context,
4771+
create=True,
4772+
),
4773+
pytest.raises(ToolError) as exc_info,
4774+
):
4775+
await _supervisor_api_call(client, "/addons")
4776+
4777+
payload = _parse_tool_error(exc_info)
4778+
message = payload["error"]["message"]
4779+
prefix = "Supervisor API GET /addons returned an invalid result payload: "
4780+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4781+
assert message.startswith(prefix)
4782+
assert message.endswith(suffix)
4783+
assert len(message) == 50 * 1024
4784+
46264785
@pytest.mark.asyncio
46274786
async def test_addon_mode_write_redirect_reports_unknown_outcome(self, monkeypatch):
46284787
"""A redirect cannot prove whether Supervisor applied the received write."""

0 commit comments

Comments
 (0)