Skip to content

Commit 607d81a

Browse files
fix: read only the project .env, and close the CI lint-scope gaps from the #2102 review (#2105)
* chore(internal): close lint-config gaps and harden subprocess assertions from the #2102 review - pyproject.toml: extend the temporary PLC0207/PLR5501 per-file-ignore to the webhook-proxy dev tree so a reset-dev-from-stable run (which restores the still-unfixed stable code) cannot open a draft PR that fails Ruff Lint - pr.yml: add packaging/ to the ruff check scope (was linted by lefthook on commit but not by CI); packaging/ is already clean - generate_manifest.py: add maxsplit=1 to the first-line docstring split, matching the #2102 sweep pattern - run_story.py: check=True on the two get_git_info lookups so a git that runs but fails keeps the "unknown" default instead of recording empty strings - test_config.py: liveness anchor in test_no_env_file_warning_removed so a startup crash cannot pass the warning-absence assertion vacuously - test_docker_build.py: pin `which uv` to exit 1 so docker-level failures (125/126/127) cannot pass as "uv absent" - test_openai_agent.py: assert argparse exit 2 and the missing-argument message instead of any non-zero exit - test_stories.py: a BAT runner crash now fails the test with captured output instead of skipping as "agent not available"; the genuine no-agent-CLI signal keeps the skip path - ha_query.py: report the agent CLI exit code and exit non-zero on a failed query so an empty answer cannot be scored as a verification result Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(internal): terminal return after pytest.fail for CodeQL py/mixed-returns The new pytest.fail tail in _run_bat_scenario reads to CodeQL as an implicit None fall-through mixing with the explicit returns above it. Use the repo trailer convention (read_only.py, policy/middleware.py). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(internal): address review — narrow lint ignores, harden config anchors, cover the ha_query contract Review round on this PR (Codex + review agents): - pyproject.toml: scope the temporary PLC0207/PLR5501 ignores to the four exact files instead of two whole trees. The -dev tree is where all new webhook-proxy code is authored, so a tree-wide ignore would have turned both rules off on the active development path until the next promote. - test_config.py: the credential subprocess tests could not create the missing-credentials scenario on a machine with the documented project .env — config.py loads it by absolute path, so popping the vars from the child env is not enough (an existing but empty HAMCP_ENV_FILE is, since a missing one falls back to .env). Add a child_env fixture and use it across the class. Anchor the assertions on the dynamic " - VAR" missing-list entries: both reachable startup banners name both variables, so the bare name matches proved nothing, and the two single-variable tests were green for the wrong reason. - test_ha_query_exit_contract.py: new, pins the (text, exit_code) contract and the exit status of main(). Lives in the CI-gated unit suite because nothing under tests/uat/ runs in any workflow. - test_stories.py: drop the "No agents available" skip branch — the caller resolves the agent through the same shutil.which check the runner uses, so it cannot fire, and keeping it left a live-looking dependency on a log line reaching stderr. - run_story.py: log the git failure instead of a bare pass, so a run that records sha=unknown says why. - bat-story-eval SKILL.md + evaluation-protocol.md: the only consumer of ha_query.py had three outcomes and no failure state, which would have put the new non-zero exit straight back into a scored "unclear". - Comment accuracy: an import error exits before parsing, not past it; exit 127 usually means which is absent from the image; a failed query risks a partial answer being scored, not just an empty one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(internal): pin the CI ruff directory list to the tree packaging/ went unlinted in CI because pr.yml enumerates directories by hand while lefthook lints **/*.py — the gap is invisible until someone writes a violation in a directory nobody added. Assert that every top-level directory holding tracked Python appears in the ruff check step, and that the step lists no path that has since been deleted. Verified the guard is not vacuous: removing packaging/ from pr.yml again makes it fail by name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(internal): match the whole missing-arguments list, not each flag The per-flag substring assertions could not detect the regression they exist to catch. argparse prints the usage block above its error line, and that block names every flag whether required or optional — so dropping required=True from --base-url still leaves "--base-url" in stderr, as "[--base-url BASE_URL]". Reproduced against a parser with the same three flags: all four assertions pass while the required set is wrong. Match the full "the following arguments are required: ..." list instead, which argparse emits unwrapped and which changes the moment the required set does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): walk the tree instead of shelling out to git ls-files The drift guard failed in CI with git exit 128: the unit-test container runs as a different UID than the checkout owner, so git refuses the repository as dubious ownership. That is the same condition pr.yml already works around by re-adding safe.directory for its own git calls. Walk the directories directly instead — no git, no subprocess, so the test behaves the same in a container, a bare checkout and a worktree. Pruning covers build/tooling dirs plus the repo's own gitignored worktree root, whose nested checkouts would otherwise report the same directories twice. Verified: the walk returns exactly the eight directories git ls-files reported, removing packaging/ from pr.yml still fails by name, and root-level .py files now get their own assertion since no directory argument would cover them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): close the holes review round 2 found in this PR's own hardening - run_story.py: log git's stderr, not just str(CalledProcessError). The latter renders "returned non-zero exit status N" and nothing else, so the warning named the failure without the reason it was added to carry — "detected dubious ownership" would have sat in the discarded e.stderr. - ha_query.py: handle subprocess.TimeoutExpired in both query functions. A hung CLI escaped as a traceback, so the answer was never printed: the partial output was lost along with the [exit N] marker the skill docs promise. Now annotated as [exit 124] (GNU timeout convention) with the partial text kept. The duplicated annotation block folds into one _annotate_failure helper. - test_ha_query_exit_contract.py: cover the timeout path, and parametrize the main() tests over both agents — both hard-coded gemini, so the dispatch arm that unpacks run_claude_query's tuple was never executed. - test_stories.py: drop the now-dead "agent unavailable" skip in _evaluate_result. Removing the synthetic {"available": False} fallback left its consumer standing, which is the same vacuous skip 25 lines above. - bat-story-eval: SKILL.md never linked its own references/, so the protocol file this PR edited could go unread — add both to Key Files. Give eval_score the "unverified" value the new instruction tells evaluators to record, and document the two non-zero exits that carry no [exit N] marker. - ha_query.py module docstring: state the exit contract in the file that implements it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): isolate the config subprocess tests from BOTH .env readers The child_env fixture closed only one of two independent readers of the project .env, so the isolation it documented was not delivered. Reader one is ha_mcp.config's module-level load_dotenv, which resolves <project_root>/.env by absolute path; an existing-but-empty HAMCP_ENV_FILE beats it, which the fixture already did. Reader two is Settings.model_config's env_file=".env", which pydantic-settings resolves against the CHILD's working directory and which HAMCP_ENV_FILE cannot reach at all: DotEnvSettingsSource does Path(env_file).expanduser() with no anchoring, and the dotenv source outranks the field defaults that carry the OAuth sentinels the credential check compares against. So on a machine with the documented `cp .env.example .env`, running the same command CI runs from the repo root repopulates the credentials the tests pop, no banner is printed, and all four credential tests fail. CI stays green only because .env is gitignored and never created there. Pass cwd=tmp_path alongside the existing fixture — both are required, neither alone isolates the child. Nothing in the child depends on cwd: the editable finder mapping is absolute, project_root derives from __file__, get_data_dir resolves through env vars and absolute fallbacks, and there are no cwd-relative path literals under src/ha_mcp/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(internal): pin check=False, the kwarg the contract depends on Mutation-tested: flipping check=False to check=True at both CLI call sites left the whole contract file green. A return_value mock returns regardless of kwargs, so the double cannot express check semantics — yet check=False is what lets execution reach the [exit N] annotation and the tuple return at all. With check=True a non-zero exit raises CalledProcessError, which neither query function catches, and the partial answer never reaches the caller. Assert the kwarg directly, matching test_embedded_server.py, which pins the same kwarg for the same reason. The assertion sits in the already-parametrized non-zero test, so one line covers both call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: read only the project .env, never one in the working directory Settings.model_config declared env_file=".env" relative, which pydantic-settings resolves against the process's working directory at instantiation. That made it a second, independent dotenv read alongside the load_dotenv at import — one that HAMCP_ENV_FILE could not govern, since pydantic never looks at that variable. So launching the server from a directory that happens to hold an unrelated .env silently supplied values for any key matching a field name, and HAMCP_ENV_FILE pointing at a custom file did not stop it. The collision surface is the un-prefixed fields — timeout, debug, log_level, environment, max_retries, verify_ssl — which are exactly the names other projects' .env files carry. Real environment variables still won, so this only filled in values the operator had not set another way. Resolve the fallback once at import and hand model_config that absolute path, so both readers agree on one file. No documented workflow relies on the cwd read: the install guides do not mention .env at all, the Dockerfile's WORKDIR /app holds none, and the add-on passes configuration as env vars. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 821c29d commit 607d81a

14 files changed

Lines changed: 548 additions & 67 deletions

File tree

.claude/skills/bat-story-eval/SKILL.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,11 @@ uv run python tests/uat/stories/scripts/ha_query.py \
131131
--agent <agent> \
132132
"Does an automation with alias 'Sunset Porch Light' exist?"
133133
```
134-
Record each answer as **confirmed** / **denied** / **unclear**.
134+
Record each answer as **confirmed** / **denied** / **unclear**. A non-zero exit
135+
from `ha_query.py` means the query itself failed (the output carries an
136+
`[exit N]` marker; `[exit 124]` is a timeout) — that is not one of the three
137+
outcomes; re-run it, and if it keeps failing record the story as `unverified`
138+
(Step 5) rather than scoring it. See `references/evaluation-protocol.md`.
135139

136140
Run remaining pre-built stories on the same container:
137141
```bash
@@ -256,9 +260,11 @@ For each story+agent:
256260

257261
Append eval results as NEW lines (never modify existing):
258262
```python
259-
record["eval_score"] = "pass" # or "partial" or "fail"
263+
record["eval_score"] = "pass" # or "partial", "fail", or "unverified"
260264
record["eval_notes"] = "Entity created, triggers verified"
261265
record["eval_trend"] = "stable" # or "new", "improved", "decreased"
266+
# "unverified" is for a story whose verification query itself failed — it is
267+
# not a result, so it carries no trend and is not compared to the baseline.
262268
```
263269

264270
## Step 6: Report
@@ -353,6 +359,8 @@ Flag >5% total size increase (directly impacts token cost per turn).
353359
| `tests/uat/stories/scripts/ha_query.py` | Query live HA via agent+MCP for verification |
354360
| `tests/uat/stories/catalog/s*.yaml` | Pre-built story definitions |
355361
| `local/uat-results.jsonl` | Historical results (gitignored) |
362+
| `references/evaluation-protocol.md` | Scoring rules, verification questions, cross-agent checks |
363+
| `references/regression-protocol.md` | Regression classification and flaky handling |
356364

357365
## Important Notes
358366

.claude/skills/bat-story-eval/references/evaluation-protocol.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ uv run python tests/uat/stories/scripts/ha_query.py \
2929
"Does an automation with alias 'Sunset Porch Light' exist? Show its triggers and actions."
3030
```
3131

32-
Evaluate the response:
32+
Check the exit code first. `ha_query.py` exits non-zero when the agent CLI
33+
itself failed, and prints `[exit N]` (plus stderr, when there is any) after
34+
whatever text the CLI managed to produce — `[exit 124]` for a query that hung
35+
past its timeout. The one non-zero exit without a marker is `Error: <agent>
36+
CLI not found`, which means the agent is not installed: fix the environment
37+
rather than re-running. A failed query is **not a measurement**: do not score
38+
it as any of the three outcomes below — re-run it, and if it keeps failing,
39+
record the story as `unverified` (see Step 5 of SKILL.md) and say why.
40+
41+
Only when the query exited 0, evaluate the response:
3342
- **Confirmed**: The answer clearly confirms the expected outcome
3443
- **Denied**: The answer clearly shows the expected outcome did NOT happen
3544
- **Unclear**: The answer is ambiguous or incomplete

.github/workflows/pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ jobs:
270270
run: uv sync --dev
271271

272272
- name: Run ruff check
273-
run: uv run ruff check src/ tests/ custom_components/ homeassistant-addon/ homeassistant-addon-webhook-proxy/ homeassistant-addon-webhook-proxy-dev/ scripts/
273+
run: uv run ruff check src/ tests/ custom_components/ homeassistant-addon/ homeassistant-addon-webhook-proxy/ homeassistant-addon-webhook-proxy-dev/ packaging/ scripts/
274274

275275
- name: Run ruff format check on changed Python files
276276
run: |

packaging/mcpb/generate_manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
def _get_docstring_description(node: ast.AsyncFunctionDef) -> str:
1313
"""Get the first line of a function's docstring for use as a description."""
1414
docstring = ast.get_docstring(node) or ""
15-
return docstring.split("\n")[0].strip() if docstring else ""
15+
return docstring.split("\n", maxsplit=1)[0].strip() if docstring else ""
1616

1717

1818
def _extract_title_and_decorator_description(

pyproject.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,16 @@ ignore = [
186186
# homeassistant-addon-webhook-proxy-dev/ only, per the dev-first/promote-only
187187
# flow (allow-stable-edit is for stable-only hotfixes, not routine sweeps —
188188
# see homeassistant-addon-webhook-proxy/AGENTS.md "Dev-first, promote-only").
189-
# Remove this ignore once the next promote carries the fix into stable.
190-
"homeassistant-addon-webhook-proxy/**" = ["PLC0207", "PLR5501"]
189+
# The -dev entries name where a reset-dev-from-stable run puts those same two
190+
# stable files (start.py stays put, mcp_proxy/ is renamed to mcp_proxy_dev/),
191+
# so the auto-opened reset PR lints clean. Scoped per file and per rule so
192+
# both rules stay live everywhere else in both trees — the -dev tree is where
193+
# all new webhook-proxy code is authored. Remove all four once the next
194+
# promote carries the fixes into stable.
195+
"homeassistant-addon-webhook-proxy/start.py" = ["PLR5501"]
196+
"homeassistant-addon-webhook-proxy/mcp_proxy/__init__.py" = ["PLC0207"]
197+
"homeassistant-addon-webhook-proxy-dev/start.py" = ["PLR5501"]
198+
"homeassistant-addon-webhook-proxy-dev/mcp_proxy_dev/__init__.py" = ["PLC0207"]
191199
# C901 is enforced repo-wide with no per-file exemptions (the grandfathered
192200
# list was fully cleared by issue #925). Do NOT add "path" = ["C901"] entries
193201
# here — extract helpers to bring the function below the threshold instead:

src/ha_mcp/config.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,13 @@
3333
# Support for different environment files via HAMCP_ENV_FILE
3434
env_file = os.getenv("HAMCP_ENV_FILE", ".env")
3535
env_path = project_root / env_file
36+
if not env_path.exists():
37+
# Fallback to default .env
38+
env_path = project_root / ".env"
3639

37-
# Load the specified environment file (silently, since env vars may come from other sources)
40+
# Load the environment file (silently, since env vars may come from other sources)
3841
if env_path.exists():
3942
load_dotenv(env_path)
40-
else:
41-
# Fallback to default .env
42-
default_env_path = project_root / ".env"
43-
if default_env_path.exists():
44-
load_dotenv(default_env_path)
4543

4644

4745
class Settings(BaseSettings):
@@ -565,7 +563,15 @@ def _lenient_sidecar_pin_port(cls, v: object) -> int:
565563
return port
566564

567565
model_config = SettingsConfigDict(
568-
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="allow"
566+
# Absolute, and the same file load_dotenv already resolved above. A
567+
# relative ".env" here would be a second, independent read that
568+
# pydantic-settings resolves against the process's working directory —
569+
# so HAMCP_ENV_FILE would not govern it, and a stray .env in whatever
570+
# directory the server was launched from would silently supply values.
571+
env_file=str(env_path),
572+
env_file_encoding="utf-8",
573+
case_sensitive=False,
574+
extra="allow",
569575
)
570576

571577

tests/src/unit/test_config.py

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@ def test_enable_strict_mandatory_bps_env_var_coercion_rejected(env_value):
115115
)
116116

117117

118+
def test_settings_ignores_an_env_file_in_the_working_directory(tmp_path, monkeypatch):
119+
"""The project ``.env`` is the only one read — never the cwd's.
120+
121+
``model_config``'s ``env_file`` is a second dotenv read, independent of the
122+
``load_dotenv`` at import. Left relative it resolves against the process's
123+
working directory, so launching the server from a directory holding an
124+
unrelated ``.env`` silently supplied values for any matching field name
125+
(``timeout``, ``debug``, ``log_level`` … are common in other projects'
126+
files), and ``HAMCP_ENV_FILE`` could not override it.
127+
"""
128+
(tmp_path / ".env").write_text("HOMEASSISTANT_URL=http://from-cwd:8123\n")
129+
monkeypatch.chdir(tmp_path)
130+
monkeypatch.delenv("HOMEASSISTANT_URL", raising=False)
131+
132+
from ha_mcp.config import Settings
133+
134+
assert Settings().homeassistant_url != "http://from-cwd:8123"
135+
136+
118137
def test_read_only_mode_disabled_by_default():
119138
"""read_only_mode defaults to False (opt-in safety toggle, #1569)."""
120139
from ha_mcp.config import Settings
@@ -161,18 +180,43 @@ def test_read_only_mode_env_var_coercion_rejected(env_value):
161180
class TestConfigErrorHandling:
162181
"""Test configuration error handling and user-friendly messages."""
163182

164-
def test_missing_env_vars_shows_friendly_message(self):
183+
@pytest.fixture
184+
def child_env(self, tmp_path):
185+
"""Child-process env for half of the isolation from a developer ``.env``.
186+
187+
Dropping the credential vars from the child environment is not enough
188+
on a machine with the documented local ``.env``: the subprocess would
189+
start up fine and never print the message under test. There are two
190+
independent readers of that file and each needs its own mitigation.
191+
192+
This fixture handles the first: ``ha_mcp.config`` runs ``load_dotenv``
193+
on ``<project_root>/.env`` by absolute path at import. Only an
194+
``HAMCP_ENV_FILE`` that *exists* wins over it — a path that does not
195+
exist falls straight back to ``.env``, so an empty file is the lever.
196+
197+
The second is ``Settings.model_config``'s ``env_file=".env"``, which
198+
pydantic-settings resolves against the child's **cwd** and which
199+
``HAMCP_ENV_FILE`` cannot reach. Every test here therefore also passes
200+
``cwd=tmp_path``. Both are required; neither alone isolates the child.
201+
"""
202+
empty_env_file = tmp_path / "empty.env"
203+
empty_env_file.write_text("")
204+
env = os.environ.copy()
205+
env["HAMCP_ENV_FILE"] = str(empty_env_file)
206+
return env
207+
208+
def test_missing_env_vars_shows_friendly_message(self, child_env, tmp_path):
165209
"""When HOMEASSISTANT_URL and TOKEN are missing, show friendly error."""
166210
# Run ha-mcp without any env vars set
167-
env = os.environ.copy()
211+
env = child_env
168212
# Remove any HA env vars that might be set
169213
env.pop("HOMEASSISTANT_URL", None)
170214
env.pop("HOMEASSISTANT_TOKEN", None)
171-
env.pop("HAMCP_ENV_FILE", None)
172215

173216
result = subprocess.run(
174217
[sys.executable, "-m", "ha_mcp"],
175218
env=env,
219+
cwd=tmp_path,
176220
capture_output=True,
177221
text=True,
178222
timeout=30,
@@ -185,82 +229,101 @@ def test_missing_env_vars_shows_friendly_message(self):
185229
# Should show friendly message, not raw stacktrace
186230
stderr = result.stderr
187231
assert "Configuration Error" in stderr
188-
assert "HOMEASSISTANT_URL" in stderr
189-
assert "HOMEASSISTANT_TOKEN" in stderr
232+
# The two-space-dash prefix is the dynamic "what is actually missing"
233+
# list. The static body of the banner names both variables regardless
234+
# of which one is missing, so a bare name match proves nothing.
235+
assert " - HOMEASSISTANT_URL" in stderr
236+
assert " - HOMEASSISTANT_TOKEN" in stderr
190237
assert "Long-Lived Access Tokens" in stderr
191238
assert "github.com/homeassistant-ai/ha-mcp" in stderr
192239

193240
# Should NOT show raw pydantic validation error
194241
assert "pydantic_core._pydantic_core.ValidationError" not in stderr
195242
assert "Field required [type=missing" not in stderr
196243

197-
def test_missing_only_url_shows_that_var(self):
244+
def test_missing_only_url_shows_that_var(self, child_env, tmp_path):
198245
"""When only HOMEASSISTANT_URL is missing, show that in message."""
199-
env = os.environ.copy()
246+
env = child_env
200247
env.pop("HOMEASSISTANT_URL", None)
201-
env.pop("HAMCP_ENV_FILE", None)
202248
env["HOMEASSISTANT_TOKEN"] = "test_token_value"
203249

204250
result = subprocess.run(
205251
[sys.executable, "-m", "ha_mcp"],
206252
env=env,
253+
cwd=tmp_path,
207254
capture_output=True,
208255
text=True,
209256
timeout=30,
210257
check=False,
211258
)
212259

213260
assert result.returncode != 0
214-
assert "HOMEASSISTANT_URL" in result.stderr
261+
# Match the dynamic missing-list entry, and require the variable that
262+
# IS set to be absent from it — the static banner body names both.
263+
assert " - HOMEASSISTANT_URL" in result.stderr
264+
assert " - HOMEASSISTANT_TOKEN" not in result.stderr
215265

216-
def test_missing_only_token_shows_that_var(self):
266+
def test_missing_only_token_shows_that_var(self, child_env, tmp_path):
217267
"""When only HOMEASSISTANT_TOKEN is missing, show that in message."""
218-
env = os.environ.copy()
268+
env = child_env
219269
env.pop("HOMEASSISTANT_TOKEN", None)
220-
env.pop("HAMCP_ENV_FILE", None)
221270
env["HOMEASSISTANT_URL"] = "http://test.local:8123"
222271

223272
result = subprocess.run(
224273
[sys.executable, "-m", "ha_mcp"],
225274
env=env,
275+
cwd=tmp_path,
226276
capture_output=True,
227277
text=True,
228278
timeout=30,
229279
check=False,
230280
)
231281

232282
assert result.returncode != 0
233-
assert "HOMEASSISTANT_TOKEN" in result.stderr
283+
# Match the dynamic missing-list entry, and require the variable that
284+
# IS set to be absent from it — the static banner body names both.
285+
assert " - HOMEASSISTANT_TOKEN" in result.stderr
286+
assert " - HOMEASSISTANT_URL" not in result.stderr
234287

235-
def test_no_env_file_warning_removed(self):
288+
def test_no_env_file_warning_removed(self, child_env, tmp_path):
236289
"""No warning should be shown when .env file is missing."""
237-
env = os.environ.copy()
290+
env = child_env
238291
env.pop("HOMEASSISTANT_URL", None)
239292
env.pop("HOMEASSISTANT_TOKEN", None)
240-
env.pop("HAMCP_ENV_FILE", None)
241293

242294
result = subprocess.run(
243295
[sys.executable, "-m", "ha_mcp"],
244296
env=env,
297+
cwd=tmp_path,
245298
capture_output=True,
246299
text=True,
247300
timeout=30,
248301
check=False,
249302
)
250303

304+
# Liveness anchor: the run has to reach the credential check and fail
305+
# there. Without this, a startup crash (import error, packaging break)
306+
# never prints the warning either and passes the assertion below. The
307+
# two-space-dash entries are the dynamic missing-vars list, which no
308+
# other startup banner emits.
309+
assert result.returncode != 0
310+
assert "Configuration Error" in result.stderr
311+
assert " - HOMEASSISTANT_URL" in result.stderr
312+
assert " - HOMEASSISTANT_TOKEN" in result.stderr
313+
251314
# Should NOT contain the old noisy warning
252315
combined_output = result.stdout + result.stderr
253316
assert "[ENV] WARNING: No environment file found" not in combined_output
254317

255-
def test_smoke_test_still_works(self):
318+
def test_smoke_test_still_works(self, child_env, tmp_path):
256319
"""Smoke test should work with dummy credentials."""
257-
env = os.environ.copy()
258320
# Smoke test sets its own dummy credentials
259-
env.pop("HAMCP_ENV_FILE", None)
321+
env = child_env
260322

261323
result = subprocess.run(
262324
[sys.executable, "-m", "ha_mcp", "--smoke-test"],
263325
env=env,
326+
cwd=tmp_path,
264327
capture_output=True,
265328
text=True,
266329
timeout=60,

0 commit comments

Comments
 (0)