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
2226Credential 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
4338from __future__ import annotations
7570# restore, and let the late write survive).
7671RESTORE_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 )
8079class EngineCredential :
@@ -88,6 +87,7 @@ class EngineCredential:
8887
8988 url : str
9089 token : str
90+ verify_ssl : bool | None = None
9191
9292
9393def 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