Skip to content

Commit edb051e

Browse files
kingpanther13claude
andcommitted
feat(screenshot): report theme changes instead of writing them back
The screenshot engine writes the saved frontend theme of the Home Assistant user its token belongs to, which syncs to that user's live web and mobile sessions (#1909). ha-mcp previously undid that with a snapshot/restore bracket, but the restore was itself a frontend/set_user_data write from tools annotated readOnlyHint: True -- which is why #1991 / PR #2014 disabled the bracket entirely, trading the protection away to keep the annotation honest. This keeps both. The guard still reads the saved theme before and after a capture, but on a change it reports the previous value instead of writing it, so the capture path issues no writes at all and the read-only annotations stay accurate. Undoing the change moves to ha_manage_theme -- already annotated destructiveHint: True / readOnlyHint: False -- via two new actions: - get_engine_theme: read the engine account's per-user theme - set_engine_theme: write it back, taking the value verbatim from the warning These act on the engine account's per-user profile (frontend/set_user_data), a different layer from the backend default that action='set' changes, and they resolve the engine credential the way the guard does rather than using ha-mcp's own client -- with a dedicated engine account those are different users, and ha-mcp's own credential cannot reach the engine user's profile. The warning also suggests giving the engine its own user and token, which avoids the problem outright: the write then lands on an account nobody looks at, and nothing is emitted. Because nothing is written on the capture path, concurrent batches cannot corrupt each other, so this needs no serialization -- and with it none of the lock's failure modes. Detection is armed for every capture, not only themed ones: the upstream fix sparing the no-parameter case (balloob/home-assistant-addons#89) is unreleased as of Puppet 2.6.0. Retained from the earlier attempt: the guard's WebSocket waits are bounded (5s, vs send_command's 30s default) so a pre-render read cannot consume the caller's MCP timeout, and the resolved credential carries the client's verify_ssl override so self-signed instances are not silently undetectable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nm7tyA1nfxNCWFXaR3AxV
1 parent 16ebe17 commit edb051e

5 files changed

Lines changed: 338 additions & 162 deletions

File tree

docs/beta.md

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,28 @@ token grants. If the token is missing or invalid, Puppet lands on the login
177177
page and (by its design) restarts; ha-mcp surfaces this as a clear "set the
178178
engine's access token" error rather than a silent failure.
179179

180-
Puppet's theme and dark-mode renderer controls used to dispatch Home
181-
Assistant's `settheme` event on every cold render, which Home Assistant
182-
persisted on the frontend profile of the user whose token the engine runs with
183-
— and synced to that user's real web and mobile sessions, flipping a dark-mode
184-
user's whole UI to light on every screenshot (#1909). Recent Puppet versions
185-
fixed that cold-render dispatch, so ha-mcp's snapshot/restore bracket around
186-
each capture is now disabled (#1991); the guard code is retained so it can be
187-
switched back on if a future engine regression reintroduces the write. If you
188-
run an older Puppet build, update the app (or your self-hosted sidecar
189-
image) — older engines still persist the theme selection and will keep
190-
flipping it. A dedicated Puppet account remains a sound belt-and-suspenders
191-
setup. Language selection is local to Puppet's browser session.
180+
Puppet dispatches Home Assistant's `settheme` event on cold renders, which
181+
Home Assistant persists on the frontend profile of the user whose token the
182+
engine runs with — and syncs to that user's real web and mobile sessions,
183+
flipping that user's whole UI on every screenshot (#1909). Upstream stopped
184+
the dispatch for renders that request no theme
185+
(balloob/home-assistant-addons#89), but by its own title only for that case,
186+
and it is unreleased as of Puppet 2.6.0 — so on current releases every render
187+
writes.
188+
189+
The screenshot and dashboard-get tools **detect** this and report it, but
190+
never write: they read the engine account's saved theme before and after the
191+
render and, when it changed, emit a warning naming the previous value. Undoing
192+
it is a separate, explicitly write-annotated call —
193+
`ha_manage_theme(action="set_engine_theme", value=...)` — so these tools stay
194+
honestly `readOnlyHint: True` (#1991). `ha_manage_theme(action=
195+
"get_engine_theme")` inspects the same value. Note this is the engine
196+
account's *per-user* profile, a different layer from the backend default that
197+
`action="set"` changes.
198+
199+
**Give the engine its own Home Assistant user and long-lived token** and the
200+
problem disappears: the write lands on an account nobody looks at, and no
201+
warning is emitted. Language selection is local to Puppet's browser session.
192202

193203
To change the Puppet engine app's own options (such as `keep_browser_open`)
194204
or to restart it, use `ha_manage_app`; the screenshot tools only render and

src/ha_mcp/dashboard_screenshot/capture.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -855,15 +855,12 @@ async def capture_dashboard_images(
855855
preset's native orientation. ``full_page`` is a compatibility alias for
856856
requesting the engine's native ``WIDTHxauto`` viewport.
857857
858-
The batch used to be bracketed by a :class:`ThemeGuard` that restored the
859-
engine user's saved frontend theme when a cold render changed it (issue
860-
#1909). That bracket is currently disabled (#1991): upstream Puppet no
861-
longer dispatches ``settheme`` on cold renders, so there is nothing to undo.
862-
The guard construction and the ``client`` / ``capture_warnings`` plumbing
863-
are retained so the bracket can be re-enabled by uncommenting the
864-
snapshot/restore calls if a future engine regression reintroduces the write;
865-
while disabled ``capture_warnings`` simply stays empty and never affects the
866-
captures themselves.
858+
Each batch is bracketed by a :class:`ThemeGuard`, which reads the engine
859+
user's saved frontend theme before and after rendering. It never writes:
860+
when the render changed the theme it reports the previous value through
861+
``capture_warnings`` so the agent can restore it with the write-annotated
862+
``ha_manage_theme``, keeping this path honestly read-only (#1909, #1991).
863+
Guard failures are non-fatal.
867864
"""
868865
path = _validate_dashboard_path(dashboard_path)
869866
options = validate_capture_parameters(
@@ -888,16 +885,14 @@ async def capture_dashboard_images(
888885
mime_type = _MIME_TYPES[options.image_format]
889886
captures: list[DashboardImageCapture] = []
890887

891-
# ThemeGuard bracket — currently DISABLED (#1991). Stock Puppet used to
892-
# dispatch a theme write into the authenticated frontend on cold renders,
893-
# which Home Assistant persisted to the engine user's real profile (#1909);
894-
# ha-mcp snapshotted before and restored after to undo it. Upstream Puppet
895-
# has since fixed the cold-render settheme dispatch, so the bracket is no
896-
# longer needed. The guard is still constructed (and the snapshot/restore
897-
# calls kept below, commented out) so it can be re-enabled by uncommenting
898-
# if a future engine regression reintroduces the write.
888+
# ThemeGuard bracket. Puppet's settheme dispatch persists onto the engine
889+
# token user's profile and syncs to that user's live sessions (#1909).
890+
# Both reads here; the guard reports the change rather than undoing it, so
891+
# this tool issues no writes and stays read-only (#1991). Armed for every
892+
# capture, not just themed ones: the upstream fix that would spare the
893+
# no-parameter case is unreleased as of Puppet 2.6.0.
899894
guard = ThemeGuard.for_capture(engine_target.addon_credential, client)
900-
# await guard.take_snapshot()
895+
await guard.take_snapshot()
901896
batch_error: ToolError | None = None
902897
try:
903898
async with httpx.AsyncClient(
@@ -977,7 +972,7 @@ async def capture_dashboard_images(
977972
# and its outcome can be attached to the error payload below.
978973
batch_error = exc
979974
finally:
980-
# await guard.restore() # ThemeGuard bracket disabled (#1991) — see above.
975+
await guard.detect_change()
981976
if capture_warnings is not None:
982977
capture_warnings.extend(guard.warnings)
983978

src/ha_mcp/dashboard_screenshot/theme_guard.py

Lines changed: 113 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,38 @@
1-
"""Snapshot and restore the engine user's saved frontend theme (issue #1909).
2-
3-
NOTE: This guard is currently disabled at its call site (``capture.py``)
4-
because upstream Puppet fixed the cold-render ``settheme`` dispatch that made
5-
the bracket necessary (#1991). The code here is retained unchanged so the
6-
bracket can be re-enabled by uncommenting the snapshot/restore calls in
7-
``capture.py`` if a future engine regression reintroduces the write.
8-
9-
Stock Puppet dispatches Home Assistant's ``settheme`` event on every
10-
cold-browser render — its ``dark`` query flag is presence-based, so "not
11-
requested" reaches the frontend as an explicit "light". Home Assistant
12-
persists that selection server-side per user (``frontend/set_user_data``,
13-
key ``"theme"``) and syncs it to every session of the user whose long-lived
14-
token the engine runs with. A plain screenshot call therefore flips a
15-
dark-mode user's real web and mobile UI to light.
16-
17-
ha-mcp cannot suppress the engine's write, so every capture batch is
18-
bracketed instead: read the engine user's saved theme before rendering and
19-
write it back afterwards when the render changed it (an unchanged value is
20-
never rewritten).
1+
"""Detect when the screenshot engine changes the engine user's saved theme.
2+
3+
Puppet dispatches Home Assistant's ``settheme`` event on cold-browser
4+
renders. Its ``dark`` query flag is presence-based, so "not requested"
5+
reaches the frontend as an explicit "light". Home Assistant persists that
6+
selection server-side per user (``frontend/set_user_data``, key ``"theme"``)
7+
and syncs it to every session of the user whose long-lived token the engine
8+
runs with, so a screenshot flips that user's real web and mobile UI (#1909).
9+
10+
Upstream stopped the dispatch for renders requesting nothing
11+
(balloob/home-assistant-addons#89), but by its own title only for that case,
12+
and it is unreleased as of Puppet 2.6.0 -- so on current releases every
13+
render writes.
14+
15+
**This guard never writes.** It reads the saved theme before the batch and
16+
again afterwards, and when the render changed it, reports the previous value
17+
so the agent can restore it with ``ha_manage_theme`` -- a tool correctly
18+
annotated as a write. That keeps the screenshot and dashboard-get tools
19+
honestly ``readOnlyHint: True`` (#1991, PR #2014) while still surfacing the
20+
damage. Because nothing is written, concurrent batches cannot corrupt each
21+
other and no serialization is needed.
22+
23+
Pointing the engine at its own dedicated Home Assistant user avoids the
24+
problem outright: the write then lands on an account nobody looks at.
2125
2226
Credential resolution mirrors engine discovery:
2327
24-
- **HA OS / Supervised** the Puppet add-on's own ``access_token`` and
28+
- **HA OS / Supervised** -- the Puppet add-on's own ``access_token`` and
2529
``home_assistant_url`` options, taken from the Supervisor add-on info that
2630
engine discovery already fetches. The token lives only in process memory
27-
for the duration of one capture batch and is never logged or returned.
28-
- **Docker / standalone / OAuth / embedded** — ha-mcp's direct Home
29-
Assistant credentials. These protect the user whenever the sidecar engine
30-
runs with a token for the same user (the common single-user setup).
31-
- Anything else (e.g. Supervisor-proxy auth with no discoverable engine
32-
token) — the guard stays inactive and captures behave as before.
33-
34-
Guard failures are always non-fatal: screenshots must keep working even
35-
when the theme cannot be protected. A snapshot or restore that was
36-
*attempted* but failed surfaces as a tool-response warning.
37-
38-
Known limit: if a real session changes the user's theme during the few
39-
seconds of a capture batch, the restore reverts that change too — the guard
40-
cannot tell the engine's write apart from a concurrent human one.
31+
and is never logged or returned.
32+
- **Docker / standalone / OAuth / embedded** -- ha-mcp's direct Home
33+
Assistant credentials, which match whenever the engine runs with a token
34+
for the same user (the common single-user setup).
35+
- Anything else -- detection stays inactive and captures behave as before.
4136
"""
4237

4338
from __future__ import annotations
@@ -75,6 +70,10 @@
7570
# restore, and let the late write survive).
7671
RESTORE_SETTLE_SECONDS = 1.0
7772

73+
# The guard runs before the engine is contacted and must never eat the
74+
# caller's MCP timeout window; send_command otherwise waits 30s by default.
75+
COMMAND_TIMEOUT_SECONDS = 5.0
76+
7877

7978
@dataclass(frozen=True, slots=True)
8079
class EngineCredential:
@@ -88,6 +87,7 @@ class EngineCredential:
8887

8988
url: str
9089
token: str
90+
verify_ssl: bool | None = None
9191

9292

9393
def addon_credential_from_options(
@@ -119,7 +119,15 @@ def _client_credential(client: Any) -> EngineCredential | None:
119119
token = str(getattr(client, "token", "") or "").strip()
120120
if not base_url.startswith(("http://", "https://")) or not token:
121121
return None
122-
return EngineCredential(url=base_url, token=token)
122+
# Carry the client's own TLS setting: a direct client built with
123+
# verify_ssl=False (self-signed HA) must not fall back to the global
124+
# default, or every session fails and no change is ever detected.
125+
verify_ssl = getattr(client, "verify_ssl", None)
126+
return EngineCredential(
127+
url=base_url,
128+
token=token,
129+
verify_ssl=verify_ssl if isinstance(verify_ssl, bool) else None,
130+
)
123131

124132

125133
@dataclass
@@ -135,6 +143,7 @@ class ThemeGuard:
135143
warnings: list[str] = field(default_factory=list)
136144
_snapshot: Any = None
137145
_snapshot_taken: bool = False
146+
changed_from: Any = None
138147

139148
@classmethod
140149
def for_capture(
@@ -157,7 +166,11 @@ async def _session(self) -> AsyncIterator[HomeAssistantWebSocketClient]:
157166
from ..client.websocket_client import HomeAssistantWebSocketClient
158167

159168
assert self.credential is not None
160-
ws = HomeAssistantWebSocketClient(self.credential.url, self.credential.token)
169+
ws = HomeAssistantWebSocketClient(
170+
self.credential.url,
171+
self.credential.token,
172+
verify_ssl=self.credential.verify_ssl,
173+
)
161174
if not await ws.connect():
162175
reason = ws.last_connect_error
163176
detail = f": {reason}" if isinstance(reason, str) else ""
@@ -173,7 +186,9 @@ async def _session(self) -> AsyncIterator[HomeAssistantWebSocketClient]:
173186
async def _fetch_theme(ws: HomeAssistantWebSocketClient) -> Any:
174187
"""Read the persisted ``theme`` frontend user-data value (may be None)."""
175188
response = await ws.send_command(
176-
"frontend/get_user_data", key=THEME_USER_DATA_KEY
189+
"frontend/get_user_data",
190+
key=THEME_USER_DATA_KEY,
191+
_wait_timeout=COMMAND_TIMEOUT_SECONDS,
177192
)
178193
payload = response.get("result") if isinstance(response, dict) else None
179194
return payload.get("value") if isinstance(payload, dict) else None
@@ -197,38 +212,79 @@ async def take_snapshot(self) -> None:
197212
f"theme before rendering; if the render changed it, {_RESTORE_HINT}."
198213
)
199214

200-
async def restore(self) -> None:
201-
"""Write the snapshot back if the render changed it. Never raises."""
215+
async def detect_change(self) -> None:
216+
"""Report -- never repair -- a theme the render changed. Never raises.
217+
218+
Writing the value back here would make the screenshot tools issue
219+
``frontend/set_user_data``, which is exactly what disqualifies them
220+
from ``readOnlyHint: True``. Instead the previous value is surfaced
221+
as a warning naming the write-annotated tool that can restore it.
222+
"""
202223
if not self._snapshot_taken or self.credential is None:
203224
return
204225
try:
205226
# Puppet's settheme dispatch happens during page navigation, but
206-
# the frontend's resulting user-data write is asynchronous let
227+
# the frontend's resulting user-data write is asynchronous -- let
207228
# it land before reading (see RESTORE_SETTLE_SECONDS).
208229
await asyncio.sleep(RESTORE_SETTLE_SECONDS)
209230
async with self._session() as ws:
210231
current = await self._fetch_theme(ws)
211-
if current != self._snapshot:
212-
# A never-configured baseline must restore as {} rather
213-
# than null: live frontend sessions ignore a null
214-
# subscription push (they would stay flipped until
215-
# reload), while an empty settings object re-applies
216-
# default/auto behavior immediately and means the same
217-
# thing on the next frontend boot.
218-
restore_value = self._snapshot if self._snapshot is not None else {}
219-
await ws.send_command(
220-
"frontend/set_user_data",
221-
key=THEME_USER_DATA_KEY,
222-
value=restore_value,
223-
)
224232
except Exception as exc:
225233
logger.warning(
226-
"Could not restore the screenshot engine user's saved theme "
234+
"Could not re-read the screenshot engine user's saved theme "
227235
"after rendering: %s",
228236
exc,
229237
)
230238
self.warnings.append(
231-
"The screenshot render may have changed the saved frontend "
232-
"theme of the engine token's user and restoring it failed; "
239+
"Could not check whether the screenshot render changed the "
240+
"saved frontend theme of the engine token's user; "
233241
f"{_RESTORE_HINT}."
234242
)
243+
return
244+
if current == self._snapshot:
245+
return
246+
self.changed_from = self._snapshot
247+
# A never-configured baseline is restored as {} rather than null:
248+
# live frontend sessions ignore a null subscription push (they stay
249+
# flipped until reload), while an empty settings object re-applies
250+
# default/auto behavior immediately and means the same thing on the
251+
# next frontend boot.
252+
restore_value = self._snapshot if self._snapshot is not None else {}
253+
logger.info(
254+
"Screenshot render changed the engine user's saved theme "
255+
"(was %s, now %s); reporting for agent-side restore",
256+
self._snapshot,
257+
current,
258+
)
259+
self.warnings.append(
260+
"The screenshot engine changed the saved frontend theme of the "
261+
"account its token belongs to, which also changes that account's "
262+
"live web and mobile sessions. This tool is read-only and will "
263+
"not change it back. To restore it, call ha_manage_theme("
264+
f"action='set_engine_theme', value={restore_value!r}). "
265+
"To stop this happening at all, give the screenshot engine its "
266+
"own Home Assistant user and long-lived token, so its writes "
267+
"land on an account nobody looks at."
268+
)
269+
270+
271+
async def read_engine_theme(credential: EngineCredential) -> Any:
272+
"""Read the engine user's saved ``theme`` frontend user-data value."""
273+
guard = ThemeGuard(credential=credential)
274+
async with guard._session() as ws:
275+
return await ThemeGuard._fetch_theme(ws)
276+
277+
278+
async def write_engine_theme(credential: EngineCredential, value: Any) -> None:
279+
"""Write the engine user's saved ``theme`` frontend user-data value.
280+
281+
Only reached through ``ha_manage_theme``, which is annotated as a write.
282+
"""
283+
guard = ThemeGuard(credential=credential)
284+
async with guard._session() as ws:
285+
await ws.send_command(
286+
"frontend/set_user_data",
287+
key=THEME_USER_DATA_KEY,
288+
value=value,
289+
_wait_timeout=COMMAND_TIMEOUT_SECONDS,
290+
)

0 commit comments

Comments
 (0)