Skip to content

Commit 10c1c96

Browse files
kingpanther13claude
andcommitted
test+fix: address Patch76 post-homeassistant-ai#1431 review (narrow except, 6-tool e2e, abs-path test)
Patch76's homeassistant-ai#1431-aware re-review (verdict: mechanism "solid... ready"). Three non-blocking items addressed: 1. Narrow the settings-lookup except (util_helpers.py). build_skill_content and attach_skill_content wrapped get_global_settings() in a bare `except Exception`, masking programming bugs (AttributeError/ImportError) the same as a genuine config issue. Narrowed both to `except ValidationError` (the realistic Settings() config-load failure named in the existing comment) so real bugs now surface, per the repo's narrow-except convention. Also flipped the attach_skill_content fallback from `master_on = True` to `False`: on a settings-fetch failure we can't know the master state, so suppress the vendor-missing warning rather than emit a misleading one whose true cause was the settings fetch. 2. e2e skill_content delivery now covers all six write tools: - test_skill_content_delivery.py: parametrized on/off coverage for script / scene / helper / dashboard (hint-is-first-key + canonical files on default; suppression on MandatoryBPS=False). Automation keeps its explicit tests incl. the BP-warning section-slice. - test_yaml_config.py: TestYamlConfigSkillContentDelivery (on/off) using the module's mcp_client_with_yaml_config fixture (ha_config_set_yaml is feature-flag + component gated, can't share the automation-dir fixtures). The six tools have distinct success-return shapes where an ordering or wrong-dict bug would slip past the structural AST test. 3. test_skill_loader.py: test_resolve_skill_files_rejects_absolute_path (`/etc/passwd` passed directly) — guards the traversal check against a future refactor to a naive prefix match. Test bookkeeping for #1: test_settings_load_raises_returns_empty now feeds a real pydantic.ValidationError (via _make_validation_error()) instead of a plain ValueError, and test_settings_load_propagates_unexpected_error pins that a non-config error (AttributeError) is NOT swallowed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4337a51 commit 10c1c96

5 files changed

Lines changed: 264 additions & 10 deletions

File tree

src/ha_mcp/tools/util_helpers.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import Any, overload
1414

1515
from fastmcp.exceptions import ToolError
16+
from pydantic import ValidationError
1617

1718
from ..client.rest_client import (
1819
HomeAssistantAPIError,
@@ -2018,10 +2019,14 @@ def build_skill_content(
20182019
# a successful write into a tool-level INTERNAL_ERROR (which would
20192020
# then prompt the agent to retry, double-applying the mutation).
20202021
# Silent degrade to "no skill_content" is the documented contract.
2022+
# Narrowed to ValidationError (the realistic config-load failure from
2023+
# Settings()) so genuine bugs (AttributeError/ImportError/etc.) still
2024+
# surface instead of being masked on every write — per the repo's
2025+
# narrow-except convention.
20212026
try:
20222027
if not get_global_settings().enable_mandatory_bps:
20232028
return {}
2024-
except Exception:
2029+
except ValidationError:
20252030
logger.warning("skill_content settings lookup failed; omitting", exc_info=True)
20262031
return {}
20272032

@@ -2146,10 +2151,14 @@ def attach_skill_content(
21462151
# Benign — return silently.
21472152
# 3. Something was requested but the vendor submodule is missing.
21482153
# Degraded — append a warning so operators notice.
2154+
# Narrowed to ValidationError (see build_skill_content). On a settings
2155+
# lookup failure we can't know the master state, so default master_on
2156+
# to False — that suppresses the vendor-missing warning rather than
2157+
# emitting a misleading one whose real cause was the settings fetch.
21492158
try:
21502159
master_on = get_global_settings().enable_mandatory_bps
2151-
except Exception:
2152-
master_on = True
2160+
except ValidationError:
2161+
master_on = False
21532162
requested_anything = MandatoryBPS or referenced_files
21542163
if master_on and requested_anything and get_skills_dir() is None:
21552164
response.setdefault("warnings", []).append(_SKILLS_VENDOR_MISSING_WARNING)

tests/src/e2e/workflows/automation/test_skill_content_delivery.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,128 @@ async def test_bp_warning_auto_embeds_only_relevant_section(
175175
assert section_body.lstrip().startswith("##"), (
176176
"section body should start with a markdown heading"
177177
)
178+
179+
# ------------------------------------------------------------------
180+
# Coverage across the other write tools (script / scene / helper /
181+
# dashboard). Automation is covered explicitly above; yaml — which is
182+
# feature-flagged and custom-component-gated — is covered in
183+
# tests/src/e2e/workflows/filesystem/test_yaml_config.py, which owns
184+
# the mcp_client_with_yaml_config fixture. Together these exercise all
185+
# six write tools that expose the MandatoryBPS parameter, each of
186+
# which has a distinct success-return shape (spread dicts, helper
187+
# subentry paths, dashboard metadata) where an ordering or wrong-dict
188+
# bug would slip past the structural AST test.
189+
# ------------------------------------------------------------------
190+
191+
def _build_create(self, tool: str, light: str, suffix: str):
192+
"""Return (tool_name, args, expected_skill_substrings) for a
193+
minimal valid create on each write tool."""
194+
if tool == "script":
195+
return (
196+
"ha_config_set_script",
197+
{
198+
"script_id": f"e2e_skill_script_{suffix}",
199+
"config": {
200+
"alias": f"E2E Skill Script {suffix}",
201+
"sequence": [
202+
{
203+
"service": "light.turn_on",
204+
"target": {"entity_id": light},
205+
}
206+
],
207+
},
208+
},
209+
["automation-patterns.md", "template-guidelines.md"],
210+
)
211+
if tool == "scene":
212+
return (
213+
"ha_config_set_scene",
214+
{
215+
"scene_id": f"e2e_skill_scene_{suffix}",
216+
"config": {
217+
"name": f"E2E Skill Scene {suffix}",
218+
"entities": {light: {"state": "on"}},
219+
},
220+
},
221+
["SKILL.md"],
222+
)
223+
if tool == "helper":
224+
return (
225+
"ha_config_set_helper",
226+
{
227+
"helper_type": "input_boolean",
228+
"name": f"E2E Skill Helper {suffix}",
229+
},
230+
["helper-selection.md"],
231+
)
232+
if tool == "dashboard":
233+
return (
234+
"ha_config_set_dashboard",
235+
{
236+
"url_path": f"e2e-skill-dashboard-{suffix}",
237+
"title": f"E2E Skill Dash {suffix}",
238+
"config": {"views": [{"title": "V", "cards": []}]},
239+
},
240+
["dashboard-guide.md", "dashboard-cards.md"],
241+
)
242+
raise AssertionError(f"unknown tool {tool}")
243+
244+
def _track(self, cleanup_tracker, tool: str, args: dict, result: dict) -> None:
245+
"""Best-effort cleanup registration for the created entity."""
246+
entity_id = result.get("entity_id")
247+
if tool == "script":
248+
cleanup_tracker.track("script", entity_id or f"script.{args['script_id']}")
249+
elif tool == "scene":
250+
cleanup_tracker.track("scene", entity_id or f"scene.{args['scene_id']}")
251+
elif tool == "helper":
252+
if entity_id:
253+
cleanup_tracker.track("input_boolean", entity_id)
254+
elif tool == "dashboard":
255+
cleanup_tracker.track("dashboard", args["url_path"])
256+
257+
@pytest.mark.parametrize("tool", ["script", "scene", "helper", "dashboard"])
258+
async def test_default_on_attaches_skill_content_all_tools(
259+
self, mcp_client, cleanup_tracker, tool
260+
):
261+
"""Default MandatoryBPS=True attaches canonical skill_content with
262+
the hint as the FIRST response key, for every write tool — not
263+
just automation."""
264+
light = await self._find_test_light_entity(mcp_client)
265+
tool_name, args, expected = self._build_create(tool, light, "on")
266+
result = await safe_call_tool(mcp_client, tool_name, args)
267+
assert result.get("success"), f"{tool} create failed: {result}"
268+
self._track(cleanup_tracker, tool, args, result)
269+
270+
keys = list(result.keys())
271+
assert keys[0] == "skill_content_hint", (
272+
f"{tool}: skill_content_hint must be the first response key, got {keys}"
273+
)
274+
skill_content = result.get("skill_content") or {}
275+
assert skill_content, f"{tool}: skill_content must be non-empty"
276+
joined = "\n".join(skill_content.keys())
277+
for sub in expected:
278+
assert sub in joined, (
279+
f"{tool}: expected {sub!r} among skill_content keys "
280+
f"{list(skill_content.keys())}"
281+
)
282+
283+
@pytest.mark.parametrize("tool", ["script", "scene", "helper", "dashboard"])
284+
async def test_mandatorybps_false_suppresses_all_tools(
285+
self, mcp_client, cleanup_tracker, tool
286+
):
287+
"""Explicit MandatoryBPS=False suppresses both skill_content and the
288+
hint, for every write tool."""
289+
light = await self._find_test_light_entity(mcp_client)
290+
tool_name, args, _ = self._build_create(tool, light, "off")
291+
result = await safe_call_tool(
292+
mcp_client, tool_name, {**args, "MandatoryBPS": False}
293+
)
294+
assert result.get("success"), f"{tool} create failed: {result}"
295+
self._track(cleanup_tracker, tool, args, result)
296+
297+
assert "skill_content" not in result, (
298+
f"{tool}: MandatoryBPS=False must suppress skill_content"
299+
)
300+
assert "skill_content_hint" not in result, (
301+
f"{tool}: MandatoryBPS=False must suppress skill_content_hint"
302+
)

tests/src/e2e/workflows/filesystem/test_yaml_config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,3 +1059,68 @@ async def test_write_read_delete_dashboard_yaml_file(
10591059
{"path": path, "confirm": True},
10601060
)
10611061
assert delete_data.get("success") is True, delete_data
1062+
1063+
1064+
@pytest.mark.filesystem
1065+
class TestYamlConfigSkillContentDelivery:
1066+
"""E2E: ha_config_set_yaml participates in the write-tool skill_content
1067+
delivery feature (#1182) — the sixth write tool exposing MandatoryBPS.
1068+
1069+
Lives here (not in workflows/automation/test_skill_content_delivery.py
1070+
with the other five tools) because ha_config_set_yaml is feature-flag
1071+
+ custom-component gated and needs the mcp_client_with_yaml_config
1072+
fixture owned by this module.
1073+
"""
1074+
1075+
_TEMPLATE_SENSOR = "- sensor:\n - name: E2E Skill Sensor\n state: 'ok'"
1076+
1077+
async def test_default_on_attaches_skill_content(self, mcp_client_with_yaml_config):
1078+
"""MandatoryBPS defaults to True → template-guidelines.md ships
1079+
under skill_content with the hint as the first response key."""
1080+
result = await safe_call_tool(
1081+
mcp_client_with_yaml_config,
1082+
TOOL_NAME,
1083+
{
1084+
"yaml_path": "template",
1085+
"action": "add",
1086+
"content": self._TEMPLATE_SENSOR,
1087+
"file": "packages/_e2e_skill_bps_on.yaml",
1088+
"backup": False,
1089+
},
1090+
)
1091+
assert result.get("success") is True, f"yaml add failed: {result}"
1092+
1093+
keys = list(result.keys())
1094+
assert keys[0] == "skill_content_hint", (
1095+
f"skill_content_hint must be the first response key, got {keys}"
1096+
)
1097+
skill_content = result.get("skill_content") or {}
1098+
assert skill_content, "skill_content must be non-empty"
1099+
assert "template-guidelines.md" in "\n".join(skill_content.keys()), (
1100+
f"expected template-guidelines.md among {list(skill_content.keys())}"
1101+
)
1102+
1103+
async def test_mandatorybps_false_suppresses_skill_content(
1104+
self, mcp_client_with_yaml_config
1105+
):
1106+
"""Explicit MandatoryBPS=False suppresses both skill_content and the
1107+
hint on the yaml tool."""
1108+
result = await safe_call_tool(
1109+
mcp_client_with_yaml_config,
1110+
TOOL_NAME,
1111+
{
1112+
"yaml_path": "template",
1113+
"action": "add",
1114+
"content": self._TEMPLATE_SENSOR,
1115+
"file": "packages/_e2e_skill_bps_off.yaml",
1116+
"backup": False,
1117+
"MandatoryBPS": False,
1118+
},
1119+
)
1120+
assert result.get("success") is True, f"yaml add failed: {result}"
1121+
assert "skill_content" not in result, (
1122+
"MandatoryBPS=False must suppress skill_content"
1123+
)
1124+
assert "skill_content_hint" not in result, (
1125+
"MandatoryBPS=False must suppress skill_content_hint"
1126+
)

tests/src/unit/test_build_skill_content.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from unittest.mock import patch
1212

1313
import pytest
14+
from pydantic import BaseModel, ValidationError
1415

1516
from ha_mcp.tools.util_helpers import (
1617
_SKILL_CONTENT_OPTOUT_HINT,
@@ -23,6 +24,21 @@
2324
)
2425

2526

27+
def _make_validation_error() -> ValidationError:
28+
"""Construct a real ``pydantic.ValidationError`` — the realistic
29+
config-load failure ``Settings()`` raises, which the narrowed
30+
``except ValidationError`` in build/attach_skill_content degrades on."""
31+
32+
class _M(BaseModel):
33+
x: int
34+
35+
try:
36+
_M(x="not-an-int") # type: ignore[arg-type]
37+
except ValidationError as exc:
38+
return exc
39+
raise AssertionError("expected ValidationError") # pragma: no cover
40+
41+
2642
@pytest.fixture
2743
def fake_skills_dir(tmp_path: Path) -> Path:
2844
"""Build a fake home-assistant-best-practices skill with reference files
@@ -374,20 +390,24 @@ def test_augment_tool_error_falls_through_on_non_json_body(self):
374390

375391
def test_settings_load_raises_returns_empty(self, patched_get_skills_dir):
376392
"""build_skill_content must silently degrade to {} when
377-
get_global_settings() raises — the documented contract is "skill
378-
content is opportunistic; never fail the surrounding write".
379-
Without this defensive wrap, a settings-validation regression
380-
would propagate, the outer except in each tool would re-map to
381-
INTERNAL_ERROR, and the agent would retry an already-committed
382-
mutation."""
393+
get_global_settings() raises a config-validation error — the
394+
documented contract is "skill content is opportunistic; never
395+
fail the surrounding write". Without this guard, a settings-
396+
validation regression would propagate, the outer except in each
397+
tool would re-map to INTERNAL_ERROR, and the agent would retry
398+
an already-committed mutation.
399+
400+
The except is narrowed to ``pydantic.ValidationError`` (the
401+
realistic Settings() config-load failure), so a genuine config
402+
problem degrades gracefully here."""
383403
# ``get_global_settings`` is imported INSIDE ``build_skill_content``
384404
# via ``from ..config import get_global_settings``, so the symbol
385405
# isn't bound in ``util_helpers``'s module namespace. Patch at the
386406
# source module instead — that's where the function-local import
387407
# resolves the name on every call.
388408
with patch(
389409
"ha_mcp.config.get_global_settings",
390-
side_effect=ValueError("settings broken"),
410+
side_effect=_make_validation_error(),
391411
):
392412
result = build_skill_content(
393413
MandatoryBPS=True,
@@ -396,6 +416,24 @@ def test_settings_load_raises_returns_empty(self, patched_get_skills_dir):
396416
)
397417
assert result == {}
398418

419+
def test_settings_load_propagates_unexpected_error(self, patched_get_skills_dir):
420+
"""A NON-config error (programming bug: AttributeError/ImportError/
421+
etc.) must NOT be swallowed — the except is deliberately narrowed
422+
to ValidationError so real bugs surface instead of being masked on
423+
every write (Patch76 review; repo narrow-except convention)."""
424+
with (
425+
patch(
426+
"ha_mcp.config.get_global_settings",
427+
side_effect=AttributeError("real bug, not a config issue"),
428+
),
429+
pytest.raises(AttributeError),
430+
):
431+
build_skill_content(
432+
MandatoryBPS=True,
433+
canonical_files=("references/automation-patterns.md",),
434+
referenced_files=None,
435+
)
436+
399437
def test_trailing_hash_resolves_to_whole_file(self, patched_get_skills_dir):
400438
"""A trailing ``#`` with empty anchor (``"path#"``) must produce
401439
defined behaviour — currently resolves to the whole file under

tests/src/unit/test_skill_loader.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ def test_resolve_skill_files_rejects_traversal(fake_skills_dir: Path) -> None:
103103
assert result == {}
104104

105105

106+
def test_resolve_skill_files_rejects_absolute_path(fake_skills_dir: Path) -> None:
107+
"""An absolute path passed directly is refused.
108+
109+
``skill_dir / "/etc/passwd"`` collapses to ``/etc/passwd`` under
110+
pathlib (absolute RHS wins), which then fails the
111+
``is_relative_to(skill_root)`` guard. Guards against a future
112+
refactor to a naive string-prefix check that would let an absolute
113+
path slip through. Input is hardcoded today, so this is
114+
defense-in-depth — but cheap to pin."""
115+
result = skill_loader.resolve_skill_files(
116+
fake_skills_dir,
117+
"home-assistant-best-practices",
118+
["/etc/passwd"],
119+
)
120+
assert result == {}
121+
122+
106123
def test_resolve_skill_files_rejects_unknown_skill(fake_skills_dir: Path) -> None:
107124
"""A skill name with no corresponding directory returns empty."""
108125
result = skill_loader.resolve_skill_files(

0 commit comments

Comments
 (0)