The biggest single failure cluster in the compat baseline (~298 suites,
282 reauth + 176 reconfigure failures): a reauth / reconfigure / reset
flow calls ConfigFlow._get_reauth_entry() / _get_reconfigure_entry(),
which resolve the entry via async_get_known_entry on the flow's hass.
That flow runs in the sandbox, whose private hass has never seen the
entry main owns — so every such flow raised UnknownEntry on its first
step and the proxy aborted it as 'sandbox_flow_error'.
FlowInit gains an optional EntrySetup 'entry' field. When the flow
context references an entry_id main owns, the proxy serialises that
entry (shared entry_to_setup_proto builder, also now used by the
entry_setup payload) and the sandbox flow runner seeds a copy into its
private config_entries before async_init — so the reauth/reconfigure
entry lookups resolve. A plain user/discovery flow (no entry_id) is
unchanged.
This unblocks those flows from erroring on step one; the terminal
async_update_reload_and_abort still mutates the sandbox's private entry
copy rather than main's — that entry-writeback crossing is the next
lever (see reports/2026-07-08/FINDINGS.md), tracked separately.
Regression tests both sides: the proxy attaches main's entry to
FlowInit; the runner resolves _get_reconfigure_entry from the seeded
copy (stash-verified to fail without the seed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
setup.py's component-setup path (the path HA boot uses for every
existing entry, and the path async_setup_component-style tests use)
calls entry.async_setup_locked directly — it never went through
ConfigEntries.async_setup, so the router hook there was skipped and a
sandboxed entry would quietly run its integration locally on main at
every restart.
The router consult moves onto ConfigEntry.async_setup — the single
funnel every setup path uses (manager, boot/component setup, the
SETUP_RETRY timer) — so a routed entry can never slip into local setup;
the manager-level helpers are deleted. ConfigEntries.async_setup keeps
a shortcut for tagged entries so a routed custom (HACS) integration
with no code on main doesn't fail component setup before reaching the
router. Regression test drives async_setup_component end-to-end and
asserts the RPC crossed and local async_setup_entry never ran
(stash-verified to fail on the unfixed tree).
The compat lane now also reports how many entries were TAGGED next to
how many engaged, and run_compat classifies tagged==0 suites as 'main'
(camera/tts/system/ALWAYS_MAIN integrations legitimately measure
vanilla behavior) instead of lumping them into the suspicious no_op
bucket — no_op now precisely means 'tagged but never routed'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Follow-ups from running the now-real compat lane against sun (8 -> 4
failures, and no more silent gaps):
- Sensor proxies rebuild native_value as datetime/date for
timestamp/date device classes — SensorEntity.state required a real
datetime and an ISO string killed the entity add ('str' object has no
attribute 'tzinfo'), leaving timestamp sensors unbridgeable.
- An integration's own-domain entities (sun.sun) now bridge: the bridge
falls back to a bare main-side EntityComponent when the domain's
component only exists inside the (sandboxed) integration setup —
restricted to the entry's own domain so a compromised sandbox cannot
mint entities in arbitrary made-up domains — and a GenericSandboxEntity
passes the pushed state + attributes through verbatim (typed domain
proxies keep their contract). The client attributes such entities to
the sole entry owning their domain when platform/registry linkage is
absent.
- Live core-config propagation: entry_setup's CoreConfig snapshot went
stale when the user changed the home location/units/language on main.
The bridge now pushes sandbox/core_config on EVENT_CORE_CONFIG_UPDATE;
the runtime applies it and re-fires the event on the private bus so
integrations recompute exactly as they would locally.
- The in-proc compat lane settles the whole sandbox round-trip inside
async_block_till_done (private hass + entity-bridge queue + channel
dispatch), giving vanilla tests their local synchronous semantics.
Both the settle loop and EntityBridge.async_drain are wall-clock
bounded: py-spy showed a clock-jump test's self-rescheduling entity
storm refilling the queue every tick, turning one teardown into 190s
of drain-spin (196s -> 12s for the sun suite).
- run_compat.py gains --jobs (thread pool of pytest subprocesses) so a
full-tree baseline doesn't take all night.
An earlier attempt forwarded tests.common.async_fire_time_changed to
the private hass; reverted within this change after it broke sun's
trigger tests — the in-proc lane shares one event loop, so main-hass
time firing already reaches the private hass's timers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
B5 leftovers: _owned_domains() is cached with dirty-flag invalidation
(proxy/platform/teardown hooks + SIGNAL_CONFIG_ENTRY_CHANGED) instead of
rescanning every config entry per inbound fire_event; StreamTransport
writes header+body without concatenating a full-frame copy;
sandbox_apply_state takes ownership of the freshly-decoded attributes
dict instead of re-copying it; climate's per-read inline import moved to
module top.
C3: bridge.py (1167 lines) sheds three verbatim regions — store.py
(SandboxStoreServer + quotas + key validation), service_forwarder.py
(forwarder build + error translation; the bridge's remember_context /
async_raw_call_service become declared interface, dropping the SLF001
pokes), description.py (SandboxEntityDescription + device-info
deserialise, dissolving the bridge<->entity type-import cycle). Down to
830 lines, no logic changes.
E3: EntrySetup now carries a typed CoreConfig snapshot
(lat/long/elevation/tz/unit-system/language/country/currency/location
name) filled from main's hass.config; the sandbox applies it before
async_setup (direct writes + async_set_time_zone — no Store persist, no
core-config event on the bare hass). Sandboxed integrations stop
computing sun times and unit conversions against HA defaults.
B4: ARCHITECTURE §5 documents the measured per-group boot cost (~0.9s /
~117MB; the upstream lazy-zone trim is tracked separately).
Also logs a newly-discovered compat gap in the plan doc: timestamp
sensor proxies push str state where SensorEntity.state needs datetime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
C1 — the wire-type constants lived in protocol.py, hand-mirrored but
excluded from the drift guard and missing four types (ping + the three
flow ops appeared as string literals in six files). All MSG_* constants
now live in the guarded, mirrored messages.py, REGISTRY is keyed by
them, production literals are gone, and both protocol.py copies are
deleted. The drift guard (script + pre-commit files pattern) now also
byte-diffs the checked-in _proto gencode pair.
C5 — dead code, each re-verified before deletion: the unused
async_update_entry(sandbox=...) plumbing (the field is now in
FROZEN_CONFIG_ENTRY_ATTRS — stronger immutability, smaller core diff);
SandboxEntityDescription.device_id (write-only);
_parse_supports_response's unreachable branches;
_translate_remote_error's duplicate branches; SandboxData.channels (no
production reader — consumers use bridges[group].channel);
drain_inflight's dead reader-task filter; Channel.from_transport
(test-only duplicate of __init__(transport=...)); _serialize_body's
None branch; uint32 wrap for _next_id on a months-lived channel;
defensive getattr on always-present dataclass fields.
E6 — sources.py: the global fetch lock serialized downloads of
different repos and _TARBALL_CACHE pinned whole tarballs for process
lifetime; per-key single-flight tasks + a completed-key set replace
both. service_mirror does one services_for_domain lookup per
registration instead of two. event_mirror's docstring described an
event_filter fast path that cannot exist (core filters receive only
event.data, and data-less events skip filtered listeners) — the
docstring now tells the truth instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
_build_registry() ran at module import time despite its docstring
promising laziness — the first register_entity RPC imported all 31
domain proxy modules (each pulling its whole component package) on
main's event loop: measured ~384 ms / +67 MB cold. proxy_class_for()
now imports just the requested domain's module on first use (memoized;
introspects the module's single SandboxProxyEntity subclass, so the
irregular names like SandboxDateTimeEntity need no table), and the
bridge warms it via the import executor so the one-time import doesn't
block the loop either. Measured: 27 ms / +7 MB for the first light
entity, 0.7 us memoized, unknown domains fall back to the generic
proxy. Deletes the 75-line hand-rolled registry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Channel (both mirrors): register_push_inline() runs a sync push handler
directly in the read loop — no task, no semaphore, nothing queued (so it
also bypasses the max_queued shed). Per-entity in-order delivery becomes
a guarantee instead of an accident of FIFO task scheduling. Calls stay
on the async task path (they must write replies); the overload shed now
logs dropped pushes instead of silently returning. Main's
_handle_state_changed and _handle_fire_event have no awaits — both are
now sync callbacks registered inline. Measured push throughput 105k ->
156k msg/s over a socketpair (and ~55k -> 156k cumulative with the
orjson wire change).
EntityBridge: the task-per-state-change model (asyncio.create_task per
EVENT_STATE_CHANGED) is replaced by a per-entity latest-state slot
drained by one writer task. Rapid bursts coalesce to the newest state
(test: 5 events -> 1 push), removal/register races are ordered by
construction (a _writing marker keeps a removal-during-register from
being misclassified), and the last update in a burst can no longer be
shed into a permanently stale proxy. Deletes the _removed_while_pending
/ _state_differs reconciliation machinery this replaces (deferred
plan-review-simplification Phase 6, now justified by measurements).
Also (plan §E5): entities whose _describe fails are skipped stickily
(cleared on their entity-registry update) instead of re-attempting a
register on every state write, and the unregister path pops _last_hash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Every google.protobuf.Struct / ListValue field on the wire becomes a
bytes field holding orjson-encoded JSON (same names + numbers; fields
whose None-vs-{} presence is load-bearing stay optional). One mirrored
encoder/decoder pair (encode_json / decode_json[_dict] in messages.py,
built on HA's json_encoder_default with a str fallback) replaces
struct_to_dict / dict_to_struct / _value_to_py / listvalue helpers and
every scattered pre-coercion (_wire_safe, flow_runner._to_json_safe,
entry_runner._json_safe).
Why (measured, plan-review-overhead §B1):
- state_changed serialize round-trip 26.3us -> 4.1us (6.5x), frames
~20% smaller; Struct.update alone cost 40x orjson.dumps.
- Struct stores every number as a double: the is_integer() restoration
hack corrupted genuine floats (2.0 -> 2) and silently lost precision
above 2^53. JSON bytes preserve int/float natively — the whole
"Numbers note" class of workarounds is deleted, with a fidelity test
(2.0 stays float, 255 stays int, 2**53+1 exact).
- The store/restore-state paths stop quadruple-converting: the
prepare_save_json bytes now ride the wire directly and main decodes
once before its unchanged validation/quota/write logic.
- Producers coerce by construction (datetime/enum/set attributes can't
crash a push), so the A1 hot-fix json_safe wrapping folds into the
encoder.
Both _pb2 mirrors regenerated; wire-module drift guard green; both
suites green; sun compat probe failure set unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Fixes the correctness findings from plan-review-overhead §A (+E4), each
with a regression test:
- A2+A5: core now owns entry state on both router paths. The
ConfigEntryRouter contract changes to raise ConfigEntryError on
failure; core maps it to SETUP_ERROR (setup) or leaves the entry
state untouched (refused unload — previously a refused unload was
wrongly marked NOT_LOADED while the remote entry stayed loaded).
Every SLF001 state poke moves out of router.py into
config_entries.py, which owns ConfigEntry.
- A4: the router setup/unload branches now honor entry.setup_lock, so
a direct async_setup during a reload can no longer run two
entry_setup RPCs concurrently for one entry.
- A3: SandboxBridge.async_teardown sweeps _mirrored_services off
hass.services — a respawned sandbox's re-registration was skipped by
the has_service guard, leaving every call on a dead channel forever.
- A6: the sandbox translation overlay merges inside
_async_get_component_strings before the title fallback, so a provider
dict without "title" gets the catalog/integration.name fill-in;
_TranslationCache._async_load returns to dev-identical.
- A7: proxy init + upsert share one _apply_description with symmetric
clearing — a dropped device_class/device_info no longer sticks.
- A8: the frame-size cap is enforced on write too (both channel
mirrors); an oversize payload fails its own call with
FrameTooLargeError (oversize replies come back as error frames)
instead of making the peer abort the whole channel.
- A1: state-attribute pushes run through json_safe — a datetime
attribute raised ValueError inside the fire-and-forget push task and
left main's proxy permanently stale. Main-side service_data/query
args get the same coercion (_wire_safe) before dict_to_struct.
- E4: FlowRunner.async_stop now stops the private hass (executor +
timer leak per in-process sandbox), and InProcessSandbox.stop runs
the graceful sandbox/shutdown first, like production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Two critical review findings (plan-review-overhead E1+E2), each the test
that would have caught the other:
E1 — FlowRunner.create built a bare HomeAssistant and never ran the
registry loads bootstrap does, so er.async_get returned an unloaded
EntityRegistry with no .entities and every real EntityPlatform add died
with AttributeError while entry_setup still ACKed ok — the sandbox
bridged zero entities for real integrations. Load the
area/category/device/entity/floor/issue/label registries in create(),
before the channel opens, so their Stores bind to the local tempdir.
Regression-tested by driving the real sun integration through
EntryRunner end-to-end.
E2 — both compat pytest plugins only installed the entry autotag; the
sandbox_inprocess/sandbox_subprocess fixtures were opt-in and no vanilla
test requests them, so hass.config_entries.router stayed None and every
tagged entry set up locally: COMPAT.md's 99.97% measured vanilla tests,
not sandbox compatibility. The plugins now inject their sandbox fixture
into every hass-using test at collection, count router-driven
entry_setups via a test-side wrapper, and report the count in the
terminal summary; run_compat.py records the count per integration,
marks green-but-never-engaged rows no_op, and fails the run outright if
nothing engaged. Probe: sun now runs 10 routed entry setups, 98/101
passing (3 real compat gaps for the lane to report).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Channel.__init__ / from_transport no longer default `codec` to JsonCodec —
it is now a required keyword. Every production construction site already
passes ProtobufCodec; a forgotten codec is now a construction-time error
instead of silently speaking JSON at a protobuf peer.
JsonCodec leaves the production channel.py (both mirrors) and moves to each
side's test helpers: tests/components/sandbox/_helpers.py (HA) and
hass_client/testing/_jsoncodec.py (client). Channel-core tests import it
from there; the two channel.py mirrors stay byte-identical. The now-unused
`import json` is dropped from channel.py and the protocol.py docstring is
updated for the required codec + relocated JSON codec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan-4 commits accidentally added generated sandbox store fixtures
(tests/testing_config/.storage/sandbox/built-in/*) written by the test
runtime. origin tracks none of testing_config/.storage; the prior
c3f0abc53cf 'stop tracking generated test .storage' untracked a sibling
file but added no ignore rule, so it recurred. Remove these and add a
targeted .gitignore so the test store dir can't be re-committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discovery flows feed the proxy non-JSON objects — a *ServiceInfo dataclass
(IPv4Address fields, sets) as the first-step user_input and a DiscoveryKey in
context — which dict_to_struct couldn't hold, crashing the flow unhandled
(the except only caught channel errors). The router routes discovery-sourced
flows to the sandbox with no source filter, so this was reachable.
Main side: _to_jsonable walks context + first-step payload into Struct-safe
primitives (dataclasses -> field dicts, IPs -> str, sets -> lists) before
dict_to_struct. Broadened the except to abort cleanly on any unmapped payload
that still trips the marshaller.
Sandbox side: _rehydrate_discovery rebuilds the real DiscoveryKey + the
source's BaseServiceInfo (zeroconf/homekit, ssdp, dhcp, usb, hassio, mqtt) so
async_step_<source> receives the type it expects. Unmapped sources (bluetooth)
leave a dict; reconstruction failures degrade to the dict with the proxy's
clean abort as the outer backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes in the flow proxy:
1. MENU support end-to-end. async_show_menu is common and marshals like a
form. Added FlowResult.menu_options (ListValue) + sort to the proto, marshal
it sandbox-side, and re-issue async_show_menu on main. A dict-form menu
(id -> label) crosses as ordered [id, label] pairs so labels survive; a
menu selection is forwarded as the sandbox flow's {"next_step_id": <chosen>}
navigation choice.
2. Leak fix. The unsupported-result-type branch set _terminated=True before
aborting, which made async_remove skip the flow_abort RPC — leaving the
sandbox-side flow in progress and wedging retries on already_in_progress if
it had set a unique_id. The non-terminal result types (external-step,
progress) no longer set _terminated, so async_remove reaps the sandbox flow.
Regenerated proto gencode for both mirrors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy CREATE_ENTRY path read only data/title/description and called
async_create_entry, which stamps the proxy class defaults VERSION=1 /
MINOR_VERSION=1 / options={}. A sandboxed flow with VERSION>1 lost its
schema version (spurious migration on next setup) and dropped options.
async_create_entry reads self.VERSION/self.MINOR_VERSION off the *instance*
(config_entries.py async_create_entry), so override the proxy instance's
values from the wire result before the call, and plumb options= through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
protobuf.Struct stores all numbers as double, so _value_to_py returned
entry.data ints, service_data ints, and the store envelope's version/
minor_version as floats — breaking socket()/isinstance(int)/range(...).
_value_to_py now coerces whole-number floats back to int
(int(v) if v.is_integer() else v); genuinely fractional values (0.5) keep
their float type. Reviewed every struct_to_dict / listvalue_to_list
consumer (flow/entry data, service_data, target, entity_query args/result,
store data, state attributes, capabilities, event_data, translations) —
none needs a whole-number float to stay float, so the global coercion is
safe; no field narrowing required.
Applied byte-identically to BOTH hand-mirrored copies:
homeassistant/components/sandbox/messages.py
sandbox/hass_client/hass_client/messages.py
(verified identical via diff). The explicit int32 proto fields (version,
minor_version, supported_features on EntrySetup) bypass the Struct and are
unaffected.
One forged-frame test per trust-boundary gate:
* fire_event: core (homeassistant_stop/call_service/state_changed) and
unowned-domain (zha_event/hue_event) events are dropped, never reach the bus.
* register_service: an unowned domain (persistent_notification) is rejected.
* register_entity: a foreign entry_id (entry.sandbox != group) is rejected;
a device_info colliding with a foreign entry's device is refused (no merge).
* translation: a forged foreign domain returned alongside the owned one is
dropped; only the requested ∩ returned survives.
* store_save: an overlong key and an oversized value are rejected; nothing
hits disk.
* context cache: a flood of distinct unknown context_ids stays bounded by
_CONTEXT_CACHE_MAX.
* channel backpressure: over the max_queued cap, inbound calls are shed with
a ChannelOverloaded error and the inflight set stops growing.
make_channel_pair gained max_queued_a/b passthrough for the backpressure test.
_handle_register_entity now requires entry.sandbox == self.group, not
just that the entry_id resolves: a compromised sandbox may only register
entities for entries main routed to *this* group. Without this it could
attach entities and pre-create devices against a victim integration's
config entry.
Also reject a device pre-create that would merge (via shared
identifiers/connections) into a device already owned by a config entry
outside this group — _reject_foreign_device_merge — closing the
device-registry hijack vector. entry.sandbox is set by main at flow
completion, never by the sandbox.
entry_setup/entry_unload are main-initiated (main supplies the entry_id),
and the store server is scoped one-dir-per-channel by construction, so no
further entry_id trust points need gating.
Test entry fixtures across bridge/entity_query/domain_proxies/
crash_recovery/proto_transport updated to tag their entries sandbox="built-in".
_handle_register_service now requires the service domain to be one this
group owns (same main-side _owned_domains() derivation as the fire_event
gate), so a compromised sandbox can no longer squat
persistent_notification.* or any unclaimed domain.service. Unowned
domains are rejected with a HomeAssistantError → remote-error frame; the
existing refuse-to-clobber-an-existing-handler check is kept.
Existing register_service tests updated to own their mock domains via a
MockConfigEntry(sandbox="built-in").
Enforce on main the same <owned_domain>_ rule the sandbox claimed to:
_handle_fire_event now drops any event that is not in the <domain>_
namespace of a domain this group owns, plus a hard deny-list of core
control-plane events (homeassistant_*, call_service, state_changed, ...)
so an owned domain can never alias a core event.
Owned-domain trust is derived purely from main-side state via the new
_owned_domains() helper (entries with entry.sandbox == self.group plus
registered proxy platform domains) — never from a sandbox-supplied
identifier. The helper is the linchpin reused by the register_service
gate in the next phase.
Drops (never raises) on a forged push, so the dispatch loop is unaffected.
Guards every fix in the cluster:
- test_crash_recovery.py (new): crash→respawn→re-register (Phase 1),
sandbox-dies-availability + recovery (Phase 2), unload-while-down
releases the platform (Phase 4).
- test_manager.py: stop()-during-spawn completes without hang (Phase 3) —
a _PausingProcess lands stop() while _spawn is suspended so
self._process is still None.
- test_channel.py: Channel.close() after EOF still closes the transport
and awaits inflight exactly once, and is idempotent (Phase 5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-trip rebuild tests for SearchMedia and Segment (the as_dict /
dataclass-asdict-vs-constructor asymmetry), per-op EntityQuery proxy tests
(media search, release notes, vacuum segments, calendar update/delete) that
assert the rebuilt typed object and the forwarded method + args, and the two
error paths: a sandbox-side ServiceValidationError translating to a
HomeAssistantError on main, and a closed channel degrading to a clean
HomeAssistantError. Client-side coverage for the EntityQuery handler:
method invocation + kwarg passing, unknown entity_id, unknown method, and a
raising method propagating its exception type on the error frame.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the three query-shaped entity APIs that have a SupportsResponse
service onto the existing call_service + return_response channel, so a
sandboxed entity answers them with real data instead of raising:
- calendar.async_get_events -> calendar.get_events service, rebuilding
list[CalendarEvent] from the response (explicit field mapping, ISO
date/datetime parse — not a **dict splat).
- weather.async_forecast_{daily,hourly,twice_daily} -> weather.get_forecasts
service; Forecast is a plain TypedDict, returned verbatim.
- media_player.async_browse_media -> media_player.browse_media service,
rebuilding the recursive BrowseMedia from its frontend-shaped as_dict.
SandboxProxyEntity._call_service grows a return_response flag that decodes
the CallServiceResult response into a dict. The sandbox-side call_service
handler now runs rich service responses (e.g. a BrowseMedia object keyed by
entity_id) through the as_dict-aware JSON encoder before packing the Struct,
yielding the exact wire shape main rebuilds from.
Caveat documented at the browse_media call site: a sandboxed player's browse
surfaces only its own sources; the media_source tree is empty inside the
sandbox (media_source runs on main). Round-trip rebuild unit tests cover the
as_dict-vs-constructor asymmetry first (plan Risk #2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-side query / subscribe / WS-only-mutation entity APIs the
fire-and-forget call_service bridge can't express (calendar listings +
event update/delete, weather forecasts, media browse/search, update
release notes, vacuum segments) previously returned empty/None silently.
Add entity.raise_not_proxied and have those proxy methods raise
HomeAssistantError instead, so the gap fails loudly until a real query
RPC lands.
todo is a special case: its To-do panel reads the sync todo_items
property that also feeds TodoListEntity.state, so it can't be a query at
all. Route it to main via SANDBOX_INCOMPATIBLE_PLATFORMS and drop the
proxy (matching the camera/image precedent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the A1 catalog hook into the two display paths a sandbox-only custom
falls through today:
- async_get_integration_descriptions (loader.py): append catalog
descriptors to the custom integration/helper buckets so the add-
integration picker lists them. On-disk customs carry richer metadata,
so the disk scan wins on a domain collision.
- _async_get_component_strings (helpers/translation.py): when a domain
has no on-disk Integration (IntegrationNotFound on main), take its
"title" from the catalog — a localized title_translations[lang] if
present, otherwise degrading to the descriptor name.
Tests: catalog entry appears in descriptions with picker name + defaults
+ helper-bucket routing; on-disk custom wins a collision; title fallback
uses title_translations and degrades to name when absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement SandboxTranslationProvider and register it into core's translation
hook from async_setup (unregistered on stop). For each requested component it:
- resolves the owning sandbox group — a loaded entry's .sandbox field wins,
else the live SandboxFlowProxy of a brand-new custom's in-progress flow
(new sandbox_group accessor on the proxy);
- carves out built-ins (Integration.is_built_in ⇒ main reads its byte-identical
disk copy, never the wire);
- batches each group's custom domains into one get_translations RPC per
language (5s timeout), and degrades to empty strings on a down/closed/slow
channel so the cache-lock overlay never blocks the frontend.
router.async_unload_entry now invalidates a sandboxed entry's cached
translations, so a reload at a new integration-source ref re-pulls fresh
strings on the next fetch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a sandbox-agnostic seam to the translation cache, mirroring the
sandbox.sources source-resolver convention:
- async_register_sandbox_translation_provider(hass, provider): a HassKey-backed
registry with an unregister callback. The provider is awaited inside the
cache load and returns {language: {domain: raw_strings}} for only the domains
it owns.
- _TranslationCache._async_load overlays the provider result onto
translation_by_language_strings after async_get_integrations and before
_build_category_cache, so sandboxed strings flow through the same flatten /
English-fallback / loaded machinery as on-disk strings. A custom sandboxed
domain (IntegrationNotFound on main) thus stops resolving to {}.
- _TranslationCache.async_invalidate + async_invalidate_translations wrapper:
the first eviction API (translations were never unloaded), called by the
sandbox when a custom integration is re-fetched at a new ref.
Core never raises on a provider; degrade-to-empty is the provider's contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the build-phase scaffolding from the test name; it just verifies ai_task
and image pin to ALWAYS_MAIN. -> test_ai_task_and_image_pin_to_main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spike (hass_client/spike/: bridge_a, bridge_b, rig, synthetic_light,
transport + tests/components/sandbox/test_spike.py) was a one-off bake-off to
choose between entity-bridge designs. Option B was chosen and shipped long ago;
nothing in production imports the spike, only its own test did. Delete it.
docs/entity-bridge-decision.md keeps the rationale and the measured numbers as
the decision record, with a note that the harness is recoverable from git
history.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each proxy entity service call now forwards as its own single
`sandbox/call_service` RPC. The per-loop-tick coalescing batcher
(_CallServiceBatcher / _BatchBucket) added complexity the first iteration
doesn't need, so it is removed; async_call_service calls _raw_call_service
directly. Behaviour is unchanged except a multi-entity area call now pays one
RPC per entity instead of one coalesced RPC.
Coalescing same-tick calls is recorded as a future optimisation in
docs/FOLLOWUPS.md (with the 200-light perf benchmark that validated it). Living
docs updated; the phase-history records are left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The final deliverable should not carry the scaffolding of the phases it was
built in. Reword comments, docstrings, and generated-output strings that named
build phases (Phase N / T1-T3 / Phase A1-A2) to describe what the code does,
and rename the phase-numbered test files:
test_phase4_subprocess -> test_subprocess
test_phase9_shutdown -> test_shutdown
test_phase13_proxies -> test_domain_proxies
test_phase14 -> test_schema_and_unload
test_phase19_devices -> test_device_registry
Comments/docstrings/filenames only; no logic changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite docs/auth-scoping-decision.md to lead with the shipped design: the
sandbox holds no credential and cannot fabricate a Context; main restores
attribution from a TTL cache of contexts it issued and falls back to
user_id=None. The reverted, never-shipped scoped-token mechanism is kept as a
clearly-marked appendix for whenever the sandbox->main websocket lands. Update
the CLAUDE.md pointer to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename sweep missed several identifiers, env vars, and the
pre-commit drift-guard hook (whose entry/files paths still pointed at
the non-existent sandbox_v2/ tree, leaving the hook broken). Rename:
- SandboxV2Data -> SandboxData, DATA_SANDBOX_V2 -> DATA_SANDBOX,
SandboxV2Error -> SandboxError (+ all references and tests)
- SANDBOX_V2_ERRORS_DIR/TRANSPORT/SOCKET_PATH -> SANDBOX_* env vars
- pre-commit hook id/entry/files: sandbox_v2/proto -> sandbox/proto
- stale sandbox_v2 paths and 'v2' wording in .dockerignore + scripts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reverted Phase-7 auth-scoping mechanism never shipped, so no
real auth store carries a legacy "scopes" key. Remove the defensive
pop in AuthStore (RefreshToken is built by explicit field mapping, so
unknown keys are ignored anyway) and its test. Reword the
ConfigEntry.sandbox load comment to state the real reason the key is
optional (non-sandboxed entries omit it) instead of referencing an
unreleased phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Squash of the pre-83a0c28229f sandbox history onto current dev. The
original commits (including the grafted standalone hass-client repo,
root cc2428c2b56) cannot replay through git rebase; the full history
remains available at the local sandbox-backup-20260707 branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ