Commit 6c7968f
authored
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
- tests/src
- e2e
- basic
- workflows
- auto_backup
- dashboards
- unit
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
517 | 517 | | |
518 | 518 | | |
519 | 519 | | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
520 | 527 | | |
521 | 528 | | |
522 | 529 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
89 | | - | |
| 89 | + | |
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
99 | | - | |
100 | | - | |
| 99 | + | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | | - | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
121 | | - | |
| 121 | + | |
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| |||
Lines changed: 76 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1613 | 1613 | | |
1614 | 1614 | | |
1615 | 1615 | | |
| 1616 | + | |
| 1617 | + | |
| 1618 | + | |
| 1619 | + | |
| 1620 | + | |
| 1621 | + | |
| 1622 | + | |
| 1623 | + | |
| 1624 | + | |
| 1625 | + | |
| 1626 | + | |
| 1627 | + | |
| 1628 | + | |
| 1629 | + | |
| 1630 | + | |
| 1631 | + | |
| 1632 | + | |
| 1633 | + | |
| 1634 | + | |
| 1635 | + | |
| 1636 | + | |
| 1637 | + | |
| 1638 | + | |
| 1639 | + | |
| 1640 | + | |
| 1641 | + | |
| 1642 | + | |
| 1643 | + | |
| 1644 | + | |
| 1645 | + | |
| 1646 | + | |
| 1647 | + | |
| 1648 | + | |
| 1649 | + | |
| 1650 | + | |
| 1651 | + | |
| 1652 | + | |
| 1653 | + | |
| 1654 | + | |
| 1655 | + | |
| 1656 | + | |
| 1657 | + | |
| 1658 | + | |
| 1659 | + | |
| 1660 | + | |
| 1661 | + | |
| 1662 | + | |
| 1663 | + | |
| 1664 | + | |
| 1665 | + | |
| 1666 | + | |
| 1667 | + | |
| 1668 | + | |
| 1669 | + | |
| 1670 | + | |
| 1671 | + | |
| 1672 | + | |
| 1673 | + | |
| 1674 | + | |
| 1675 | + | |
| 1676 | + | |
| 1677 | + | |
| 1678 | + | |
| 1679 | + | |
| 1680 | + | |
| 1681 | + | |
| 1682 | + | |
| 1683 | + | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
1616 | 1692 | | |
1617 | 1693 | | |
1618 | 1694 | | |
| |||
0 commit comments