Skip to content

Commit 6c7968f

Browse files
feat: embed inline dashboard resources as data: URIs instead of the Cloudflare worker (#2069)
* feat: embed inline dashboard resources as data: URIs instead of the Cloudflare worker Inline resources (content=) now register a self-contained data:text/javascript;base64 (or text/css) URI directly in HA's resource registry, so dashboard JS/CSS never transits third-party infrastructure. The worker existed because of issue #71's claim that browsers reject data: URIs for ES6 modules; that claim was untested and is wrong at every layer (no WS URL validation, no CSP in HA, browsers load data: modules fine — verified live on HA 2026.7). Legacy worker URLs are still recognized and decoded locally for preview and migrate to data: URIs on update; nothing ever calls the retired worker. Closes #2060 * fix: harden inline data: URI handling per review - Reject data: URLs in url= mode (guard-bypass of the #1072 misroute rejection and inline validations) - Refuse re-saving a resource with its own truncated list preview (data-loss path when an agent follows the migration hint without include_content=True; hint now names the prerequisite) - Claim only ha-mcp media types as inline; anchored legacy-worker matching; validate=True base64; case-insensitive scheme; CSS charset - Keep MAX_CONTENT_SIZE at 24KB (auto-backup and per-page-load footprint amplify the URL size; the old URL-length reason is gone) - Decode inline content only for the returned page; _size in bytes * feat: raise inline content cap to 128KB The 24KB bound was the worker's URL-path limit; data: URIs have none. 128KB fits established single-file card bundles without truncation. * fix: address Codex review findings - inline_count now counts only decodable inline resources, so the list summary can never disagree with the per-resource _inline markers (shared _decode_inline_content is the single source of truth) - Normalize URLs the way WHATWG URL parsing does (strip leading C0/space, remove tab/newline) before the data: scheme check — a padded ' data:...' bypassed the url= guard yet still loads in a browser - Annotate the e2e raw-resource helper's parameters - Drop _is_inline_url, superseded by _decode_inline_content * fix: address PR review toolkit findings Correctness / safety: - The truncated-preview guard now FAILS CLOSED. It previously swallowed every listing failure and let the write through: a timeout on the (now much larger) resource listing does not imply a dead connection, so the small upsert frame right after would succeed and destroy the card while reporting success. It also ignored HA error envelopes and could raise a bare AttributeError on a malformed entry. - Block lovelace/resources/{create,update,delete} in the shared WS write blocklist. Without them the raw ws_command escape hatch and the code sandbox bypassed auto-backup, the #1072 YAML-misroute rejection, the size cap and the new data:-URL guard. - Legacy worker decoding is now strict (rejects non-alphabet payloads, which urlsafe_b64decode silently discards) and matches its origin case-insensitively so a HTTPS:// variant keeps its migration path. Response shape: - Decode every resource exactly once and feed both the summary and the page from that one result: inline_count and the _inline markers are now the same computation, and nothing is decoded twice. - Bound include_content responses with a byte budget. Over-budget rows are FLAGGED (_content_truncated), never shortened — a partial payload written back is exactly how a resource gets destroyed. - Flag recognized-but-undecodable resources (_decode_error) instead of leaving them indistinguishable from foreign ones, and cap the echoed URL for those rows. - Emit the legacy migration hint once per response instead of ~330 characters per resource. Types / docs: - _decode_inline_content returns tuple|None so (None, True) is unrepresentable; derive the accepted data: prefixes from _DATA_URI_MIME so the read and write sides cannot drift; thread the resource_type Literal through the private helpers. - Replace the hand-rolled WHATWG scheme normalizer with urlsplit (verified equivalent over 23 cases including adversarial input). - Document the reverse-proxy CSP failure mode on the tool itself. Tests: byte-vs-character size semantics, cap boundary, all four accepted prefixes, invalid-UTF-8 and corrupt legacy payloads, guard fail-closed paths, the narrow preview trigger, the content budget, a page-scope probe, the new blocklist entries, and an e2e near-cap round trip. * fix: green the CodeQL quality gate and close remaining review gaps CI (CodeQL Code Quality / python) failed on py/implicit-string-concatenation-in-list at tools_resources.py:831 — adjacent literals inside a suggestions list read as a missing comma. Fixed that one and a second instance added in the same batch, and re-scanned all changed files to confirm none remain. Remaining toolkit gaps, all folded in here: - A create that returns no resource_id now fails instead of reporting success with resource_id: None, which left the caller no handle to update or delete what it had just created (guard is create-only; an update still echoes its known id). - test_set_with_none_resource_id_routes_to_create mocked the create response with a 'resource_id' key that _extract_resource_id never reads (it reads 'id', which is what HA returns), so the test silently produced resource_id=None. Mock corrected to the real shape. - Direct test for _data_uri_for's invalid-type guard, which was reachable only through a caller that rejects 'js' earlier. - e2e: extract the repeated stored-URL assertion into _assert_stored_as_data_uri; make test_list_resources_include_content assert real semantics (it previously passed with the flag ignored); add an auto-backup capture/restore lane for an INLINE resource, whose URL carries the whole payload, asserting the restored content is byte-identical. * test: bump per-lane skip ceilings for the new external_only e2e E2E Validation (embedded) failed test_session_skipped_count_below_ceiling: 130 skips against a ceiling of 129. Cause is intentional and mine — the new inline dashboard_resource auto-backup test lives in TestDashboardResourceCaptureRestore, which is marked external_only, so it skips on every lane that skips external_only. Bumped all three such lanes rather than only the one CI reported, since the HAOS lanes would otherwise fail the same assertion on their next run: embedded 129->130, haos_inaddon 74->75, haos_embedded 103->104. container and haos are unchanged — external_only tests RUN there. Static derivation comments updated to match (auto_backup external_only 18->19). _COLLECTION_FLOOR needs no change: it is a minimum, so the three added e2e tests only raise the collected total. * fix: bound decoded-content retention to the page; fix review findings Patch76's review, all four confirmed against the code. Retention (concern 1): _summarize_resources kept every decoded payload in decoded_by_index, so a limit=1 call sat on the whole registry's content — a real regression on the default include_content=False path, where the base kept only a 150-char preview per resource and released the rest, now at up to 128KB each. Decoding stays whole-set (an accurate inline_count needs it, and only a real decode separates a genuine inline resource from a corrupt payload that looks like one), but retention is now confined to [offset, offset+limit). Nothing is decoded twice. The claim was also stated three ways that were not true: the PR body, the test name test_list_decodes_only_the_returned_page, and its docstring all said decoding was page-scoped, and its probe asserted on what the renderer received — which stays green with decoding fully global, so it could not pin the property it was named for. Test renamed to ..._retains_content_for_the_returned_page_only, probe re-pointed at the retained decode map, plus a direct _summarize_resources test proving counting spans the registry while retention is confined to the window. PR body corrected. e2e (concern 2): test_large_inline_resource_round_trips listed with a fixed limit=1/offset=0 and guarded its assertion with 'if match is not None and _content in match', so whenever the new resource was not the first registry row the tool read-back leg verified nothing and the test still passed — leaving exactly the path the migration flow depends on unpinned. It now locates the resource's offset first, fetches that row so the full content budget is available to it, and asserts unconditionally that it is present, untruncated, and byte-identical. Nits: zip(strict=True) in _process_resource_list (the sole caller builds page_decoded at exactly len(page), so this is a free assertion in code that is otherwise careful about silent truncation), and encode content to UTF-8 once per row instead of twice. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent c1404c7 commit 6c7968f

8 files changed

Lines changed: 1542 additions & 156 deletions

File tree

src/ha_mcp/tools/tools_resources.py

Lines changed: 496 additions & 82 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/util_helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,13 @@ def unwrap_service_response(result: dict[str, Any]) -> dict[str, Any]:
517517
"lovelace/dashboards/create",
518518
"lovelace/dashboards/delete",
519519
"lovelace/dashboards/update",
520+
# Resource writes carry the same wrapping-tool validation as the
521+
# dashboard commands above -- auto-backup, the #1072 HA-config-YAML
522+
# misroute rejection, the inline size cap, and the data:-URL routing
523+
# guard all live in ha_config_set_dashboard_resource (#2060).
524+
"lovelace/resources/create",
525+
"lovelace/resources/delete",
526+
"lovelace/resources/update",
520527
"config/area_registry/delete",
521528
"config/area_registry/disable",
522529
"config/area_registry/update",

tests/src/e2e/basic/test_backend_dispatch_smoke.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
# marker-gated additions rather than runtime skips.
8787
"container": 72, # was 71; +1 Puppet-management test (haos_only + inaddon_only)
8888
"haos": 46, # was 45; +1 py3.14 invalidate_caches recovery e2e (container_only)
89-
"haos_inaddon": 74, # was 73; +1 visibility enforce-mode e2e (external_only, #2015)
89+
"haos_inaddon": 75, # was 74; +1 inline dashboard_resource auto-backup e2e (external_only, #2060)
9090
# Embedded backend (#1527, E2E_BACKEND=embedded). Skips exactly the container
9191
# lane's marker-skips PLUS two embedded-specific additions:
9292
# - haos_only + inaddon_only tests skip on embedded just like on container
@@ -96,11 +96,11 @@
9696
# workflows/embedded smoke test (not_on_embedded).
9797
# Static def-level derivation (Docker-less, so parametrize item-inflation isn't
9898
# visible locally): haos_only 52 + inaddon_only-outside-haos 11 + external_only
99-
# 35 (auto_backup 18, supervisor_mock 15, self_update_notice 1, file_operations
100-
# 1) + not_on_embedded 2 = 100. Initially set to 115 as a buffer for
99+
# 36 (auto_backup 19, supervisor_mock 15, self_update_notice 1, file_operations
100+
# 1) + not_on_embedded 2 = 101. Initially set to 115 as a buffer for
101101
# parametrize item-inflation; round 6 (run 28709196071) observed the exact
102102
# item count and the entry below is pinned to it.
103-
"embedded": 129, # was 128; +1 py3.14 invalidate_caches recovery e2e (not_on_embedded)
103+
"embedded": 130, # was 129; +1 inline dashboard_resource auto-backup e2e (external_only, #2060)
104104
# HAOS embedded backend (#1527, HAOS_TEST_MODE=embedded). A HAOS lane, so it
105105
# skips the SAME set as the external HAOS lane (container_only + inaddon_only)
106106
# PLUS two haos_embedded-specific additions:
@@ -112,13 +112,13 @@
112112
# the session backend already enables the entry + drives the server.
113113
# Static def-level derivation (Docker/HAOS-less locally, so parametrize
114114
# item-inflation isn't visible): container_only 16 + inaddon_only 20 +
115-
# external_only 39 + smoke 3 = 78 (no overlaps: no external_only test is also
115+
# external_only 40 + smoke 3 = 79 (no overlaps: no external_only test is also
116116
# container_only/inaddon_only, and the 2 not_on_embedded tests are already
117117
# container_only). Applying the ~1.16x parametrize inflation the other HAOS
118118
# lanes show (haos def 30 → ~35 observed; haos_inaddon def 50 → ~58) gives
119119
# ~84; initially set to 90 with a small buffer, and round 8 observed
120120
# exactly 90 — the entry below is pinned to the observed count.
121-
"haos_embedded": 103, # was 102; +1 py3.14 invalidate_caches recovery e2e (container_only)
121+
"haos_embedded": 104, # was 103; +1 inline dashboard_resource auto-backup e2e (external_only, #2060)
122122
}
123123

124124

tests/src/e2e/workflows/auto_backup/test_capture_and_restore.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,6 +1613,82 @@ async def test_dashboard_resource_full_loop(
16131613
{"resource_id": resource_id},
16141614
)
16151615

1616+
async def test_inline_dashboard_resource_full_loop(
1617+
self, mcp_client, monkeypatch: pytest.MonkeyPatch
1618+
) -> None:
1619+
"""Same loop for an INLINE resource, whose URL is the content itself.
1620+
1621+
The url= lane above snapshots a ~30-character URL. An inline
1622+
resource's URL is a data: URI carrying the whole card, so this is
1623+
the case where capture and restore actually move the payload — and
1624+
the one the inline size cap is reasoned about (auto-backup stores
1625+
the URL whole on every edit).
1626+
"""
1627+
_enable_auto_backup(monkeypatch)
1628+
suffix = uuid.uuid4().hex[:8]
1629+
original = f"export const E2E_BK = '{suffix}';" + ("// pad" * 200)
1630+
1631+
create = await safe_call_tool(
1632+
mcp_client,
1633+
"ha_config_set_dashboard_resource",
1634+
{"content": original, "resource_type": "module"},
1635+
)
1636+
if create.get("success") is False:
1637+
pytest.skip(f"inline dashboard_resource create unsupported: {create}")
1638+
resource_id = create.get("data", {}).get("resource_id") or create.get(
1639+
"resource_id"
1640+
)
1641+
assert resource_id, f"resource_id missing: {create}"
1642+
await asyncio.sleep(_HA_PROPAGATION_SETTLE_SECONDS)
1643+
1644+
edit = await safe_call_tool(
1645+
mcp_client,
1646+
"ha_config_set_dashboard_resource",
1647+
{
1648+
"resource_id": resource_id,
1649+
"content": f"export const E2E_BK_EDITED = '{suffix}';",
1650+
"resource_type": "module",
1651+
},
1652+
)
1653+
assert edit.get("success") is not False
1654+
1655+
backup_name = await _wait_for_backup(
1656+
mcp_client, domain="dashboard_resource", entity_id=str(resource_id)
1657+
)
1658+
1659+
restore = await safe_call_tool(
1660+
mcp_client,
1661+
"ha_manage_backup",
1662+
{"scope": "edits", "action": "restore", "backup_name": backup_name},
1663+
)
1664+
assert restore.get("success") is True
1665+
1666+
# The restored resource must carry the ORIGINAL content back,
1667+
# byte-identical — a snapshot that dropped or mangled the data: URI
1668+
# would still "restore" successfully without this assertion.
1669+
listed = await safe_call_tool(
1670+
mcp_client,
1671+
"ha_config_list_dashboard_resources",
1672+
{"include_content": True},
1673+
)
1674+
restored = next(
1675+
(r for r in listed.get("resources", []) if r.get("id") == resource_id),
1676+
None,
1677+
)
1678+
assert restored is not None, "restored resource missing from listing"
1679+
assert restored.get("_content") == original
1680+
1681+
await safe_call_tool(
1682+
mcp_client,
1683+
"ha_manage_backup",
1684+
{"scope": "edits", "action": "delete", "backup_name": backup_name},
1685+
)
1686+
await safe_call_tool(
1687+
mcp_client,
1688+
"ha_config_delete_dashboard_resource",
1689+
{"resource_id": resource_id},
1690+
)
1691+
16161692

16171693
# ---------------------------------------------------------------- calendar/todo lanes
16181694
#

0 commit comments

Comments
 (0)