diff --git a/sandbox/ARCHITECTURE.md b/sandbox/ARCHITECTURE.md index 3f0b8bf4b1f0..e91c5f9f60c7 100644 --- a/sandbox/ARCHITECTURE.md +++ b/sandbox/ARCHITECTURE.md @@ -19,8 +19,8 @@ running everything locally. A user who adds a light integration through the frontend ends up with a device plus entities in main's registries, working area targeting (`light.turn_on` against an area resolves the sandboxed lights like any other light), and the -integration's services and events available on main — with the integration code -only ever executing inside the sandbox. +integration's services, events, and translations available on main — with the +integration code only ever executing inside the sandbox. The sandbox is **stateless**: it holds no persistent state of its own. Its storage and restore-state route to main (§9), and even the integration's *code* diff --git a/sandbox/OVERVIEW.md b/sandbox/OVERVIEW.md index 5cbc80a7c19a..ef8c14943d33 100644 --- a/sandbox/OVERVIEW.md +++ b/sandbox/OVERVIEW.md @@ -27,8 +27,8 @@ Run a Home Assistant integration's setup, config flow, entities, services, and events fully inside an **isolated subprocess** ("sandbox"), while the main HA instance keeps a **single, unified view** of devices, -entities, services, and events that looks identical to running -everything locally. +entities, services, events, and translations that looks identical to +running everything locally. A user adding a light integration through the frontend should end up with a device + entities in the main instance's registries, area diff --git a/sandbox/architecture.html b/sandbox/architecture.html deleted file mode 100644 index e217727b85de..000000000000 --- a/sandbox/architecture.html +++ /dev/null @@ -1,2780 +0,0 @@ - - -
- - -- A backend rewrite that runs each integration's setup, config flow, - entities, services, and events inside an isolated subprocess — while - main keeps a single, unified view of devices, entities, services, and - events that looks identical to running everything locally. -
-- Architectural overview · Phases 0–17 landed · - 99.67 % compat-lane pass rate -
-current_sandbox
- - Home Assistant runs a large universe of integrations — built-ins - for first-party platforms (Z-Wave, Hue, MQTT…), plus a long tail - of custom integrations installed via HACS. They all share a single - Python process. That means one misbehaving integration can leak memory - into the host, mishandle exceptions and bring down the event loop, - monkey-patch shared internals, or read state from unrelated - integrations. -
- -
- Sandbox puts each integration's code in an isolated subprocess (a
- "sandbox"). From the user's perspective, nothing changes: they add a
- light integration through the frontend, devices and entities show up in
- the registries, area targeting works (light.turn_on against
- an area resolves the sandboxed lights like any other light), the
- integration's services and events are available on main — with the
- integration's code only ever running inside the subprocess.
-
automation, the system looks exactly the same as if
- everything ran locally.
- EntityComponent.async_register_remote_platform,
- ConfigEntries.router, ConfigEntry.sandbox,
- current_sandbox. The PR diff is slightly larger; the
- maintenance story is dramatically smaller.
-
- Main holds the canonical view of the world. Each sandbox group is a
- separate Python process spawned lazily on first need. The two sides
- communicate over a protobuf channel that rides a pluggable transport
- (stdio by default, unix socket opt-in). Inside the sandbox, a private
- HomeAssistant instance hosts the integration's real
- async_setup_entry, ConfigFlow, entities,
- services, and events.
-
- The classifier is the brain of the routing layer: a
- pure function from a loaded Integration to
- a SandboxAssignment. It runs in two places — from
- SandboxFlowRouter.async_create_flow when a new flow starts,
- and from SandboxFlowRouter.async_setup_entry when an
- existing entry is being set up — and never imports the integration
- to make its call. The check uses
- Integration.platforms_exists() for the same reason.
-
integration_type == "system" → main.
- System integrations are part of the runtime; sandboxing them is
- meaningless.
- domain in ALWAYS_MAIN → main. A
- small deny-list: script, automation,
- scene, cloud, ai_task,
- image. Each entry has an inline "why" in
- const.py. ai_task and
- image were added by the Phase 1 spike because their
- service handlers do non-idempotent pre-dispatch work that neither
- bridge option intercepts cleanly.
- SANDBOX_INCOMPATIBLE_PLATFORMS →
- main.
- Audio / byte-stream platforms the control channel can't ferry:
- stt, tts, conversation,
- assist_satellite, wake_word,
- camera.
- Sandbox("custom").
- Sandbox("built-in").
- | Group | -Hosts | -Default sharing | -
|---|---|---|
| main | -- Nothing — matches above route here, no sandbox process - | -n/a | -
| built-in | -Every other built-in integration | -
- share_states, share_entity_registry,
- share_areas all True
- |
-
| custom | -Every custom (HACS / user) integration | -All sharing False (locked down) | -
custom defaults to locked down. A
- custom integration is the most likely vector for an attacker (HACS pulls
- code from arbitrary GitHub repos). A custom integration that
- needs to see main's state can opt in explicitly — the
- platform refuses to give it a default pass.
- main
- SandboxManager.ensure_started(group) creates the subprocess
- only when the first flow or entry routes to it. The subprocess command
- is:
-
python -m hass_client.sandbox \
- --name <name> \
- --url stdio:// \
- --token <sandbox access token>
-
-
- --url selects the control-channel transport:
- stdio:// (the default — frames over the subprocess's
- stdin/stdout) or unix://<path> (the manager opens a
- unix socket and the runtime dials back). ws:// /
- wss:// are reserved for the deferred websocket transport
- and rejected for now. The runtime opens the channel and sends a
- Ready frame (sandbox/ready) as its first
- message; the manager treats its arrival as “running” —
- there is no stdout text marker, so stdout carries nothing but channel
- frames. Frames are protobuf and length-prefixed (4-byte big-endian
- length + body) on the stream transports.
-
- SandboxProcess._supervise watches the subprocess for
- unexpected exits. Restart-on-crash is bounded:
- 3 attempts within a 60 s sliding window, with a
- small backoff sleep between attempts. Exceeding the budget transitions
- the sandbox to failed and
- ensure_started raises
- SandboxFailedError — the router surfaces this as
- SETUP_RETRY on the affected entries.
-
- A sandbox/ping handler is registered and exercised by the
- subprocess test; the periodic 30 s ping loop is wired through but
- currently disabled (process-exit detection covers the hard-crash case).
-
- On EVENT_HOMEASSISTANT_STOP the integration runs the
- following dance — the goal is that every sandboxed entity's last
- state survives into the next boot's RestoreEntity:
-
manager.async_graceful_shutdown_all(timeout=manager.shutdown_grace)
- fans out sandbox/shutdown to every running sandbox.
- config_entries.async_unload, snapshots
- RestoreStateData.async_get_stored_states() into a
- JSON-safe wrapped dict (round-tripped through orjson's HA-aware
- encoder), returns it in the reply, then schedules its own shutdown
- event via call_soon after the reply is queued so
- the subprocess exits 0 on its own.
- SandboxData's
- on_shutdown_reply callback, which writes
- restore_state to
- <config>/.storage/sandbox/<group>/core.restore_state
- via the bridge's store server.
- manager.async_stop_all() falls through to SIGTERM, then
- SIGKILL, for any sandbox that didn't ack the graceful round-trip.
-
- On the next boot the runtime warm-loads
- core.restore_state against a vanilla
- Store before any handler registers, so the first
- RestoreEntity.async_get_last_state() sees the previous
- run's state.
-
current_sandbox before the warm-load, and
- Store's IO methods read the contextvar at call time, so the
- load routes to main even though restore_state.py captured
- the original Store reference at import. (Phase 8's
- module-level storage rebinding couldn't reach that captured reference,
- so it needed a dedicated sandbox-backed Store instance here
- as a workaround — the contextvar made that unnecessary.)
- - The channel is split into three layers so the wire format and the byte - transport can each change without touching the concurrency-critical - dispatch core: -
- -Channel — the dispatch core
- (pending-id map, inflight semaphore, register / call / push / close).
- It speaks Frame objects and never touches raw bytes.
- Codec — Frame ↔
- bytes. ProtobufCodec is the production wire (a typed
- protobuf Frame envelope; the codec owns the
- type → message registry). The
- line-oriented JsonCodec is kept only as the channel-core
- test/debug wire.
- Transport — moves whole frame
- blobs. StreamTransport length-prefixes each frame (4-byte
- big-endian length + body) over a reader/writer pair (stdio, unix
- socket); a future WebSocketTransport drops in via
- Channel.from_transport.
-
- Both sides of the bridge share an identical
- protocol.py (type-string constants, mirrored verbatim in
- homeassistant/components/sandbox/protocol.py and
- sandbox/hass_client/hass_client/protocol.py) and an
- identical messages.py / generated _pb2 pair. A
- call_service request, for example, is a
- CallService message (domain,
- service, plus Struct target /
- service_data for the dynamic fields) wrapped in the
- Frame envelope.
-
Three message shapes ride the channel:
- -id; the reply echoes it. The
- originator awaits on a future keyed by id.
- register_entity, state_changed,
- fire_event, register_service, etc. (the
- recipient may still ack via a notification of its own for
- backpressure, but there's no per-message future).
- channel.call(…) re-enters the
- reader and can deadlock against its own pending reply. Phase 9
- worked around the specific case (restore_state in the shutdown reply);
- the general fix is one task per inbound call so the reader can keep
- draining the wire. This also unblocks firing
- EVENT_HOMEASSISTANT_FINAL_WRITE on sandbox shutdown.
- - This is where the user-facing magic happens. From the frontend's point - of view, adding a sandboxed integration looks identical to adding a - local one — the same form fields, the same error states, the same - final "Created entry" toast. -
- -
- HA Core's ConfigEntries grows a single
- router attribute (introduced in Phase 4), consulted
- from three call sites:
-
ConfigEntriesFlowManager.async_create_flow — when a
- new flow starts.
- ConfigEntries.async_setup — when an existing entry
- is being set up.
- ConfigEntries.async_unload — consults
- router.async_unload_entry before falling through to
- entry.async_unload(hass) (Phase 14).
-
- SandboxFlowRouter.async_create_flow runs the routing logic
- in order: look up any existing entry for the handler key, fall back to
- classify(integration), then either return
- None (let HA handle it locally) or hand back a
- SandboxFlowProxy ConfigFlow. The proxy issues
- sandbox/flow_init, sandbox/flow_step, and
- sandbox/flow_abort RPCs against the matching sandbox's
- runtime; each RPC returns a marshalled FlowResult that the
- proxy re-issues as async_show_form /
- async_create_entry / async_abort so the
- framework treats the result as native.
-
- When the sandbox returns a final create_entry result, main
- creates the ConfigEntry in its own store with
- entry.sandbox = "<group>". On the next
- ConfigEntries.async_setup(entry_id), the router sees that
- tag, ensures the sandbox is running, and round-trips an
- entry_setup RPC.
-
sandbox by calling
- async_update_entry(entry, sandbox=…) right after the
- framework creates the entry. That didn't survive contact with the
- framework: async_add invokes async_setup
- inside its own body, before any after-hook fires — so
- setup would run with sandbox=None and the router would
- never route. The fix was to extend ConfigFlowResult with a
- sandbox key (the same plumbing as
- minor_version, options,
- subentries) and read it at the entry constructor. Atomic,
- smaller, and reuses the framework's own convention.
- _SandboxFlowManager
- The integration's real ConfigFlow runs inside a
- _SandboxFlowManager — a
- ConfigEntriesFlowManager subclass that short-circuits the
- CREATE_ENTRY path. Main is the canonical owner of the
- ConfigEntry, so the sandbox never tries to add an entry to
- its own private store. The framework still validates the flow, validates
- form data, and computes the final result — only the persistence
- step is suppressed.
-
data_schema is stripped on the wire. The
- flow runner sets _has_data_schema: True when it removed a
- schema; the proxy logs a debug message when it sees the flag. A
- voluptuous_serialize-based bridge is the obvious
- follow-up — without it, frontend forms for sandboxed
- integrations can't render argument hints.
- unique_id is not propagated from the
- sandbox's flow.context back to the proxy's
- flow.context. The framework's duplicate detection on main
- can miss this until that small marshalling extension lands.
-
- This was the riskiest design choice in the whole rewrite — how do
- entity service calls (light.turn_on,
- switch.toggle,
- climate.set_temperature…) cross the sandbox
- boundary? Phase 1 was a spike that compared two candidate designs
- head-to-head with measured numbers.
-
| Option | -Wire shape | -Sandbox-side handler | -
|---|---|---|
| A — method-forward RPC | -
- sandbox/entity_method_call with
- (entity_id, method, kwargs)
- |
-
- getattr(entity, method)(**kwargs) — bypasses
- the service layer
- |
-
| B — action-call forwarding | -
- Generic sandbox/call_service with
- (domain, service, target, service_data)
- |
-
- hass.services.async_call(…) — the
- sandbox's normal dispatcher validates and invokes the entity
- method on the (already-resolved) entity ids
- |
-
- 100-entity area light.turn_on, in-process transport, 5
- iterations median:
-
| Option | -Median (ms) | -Per entity (ms) | -Glue LOC per domain | -
|---|---|---|---|
| A | -~46 | -~0.46 | -42 | -
| B | -~64 | -~0.64 | -48 | -
- Option B is ~0.18 ms slower per entity (the cost is HA's full - service handler — target resolution, schema validation, per-entity - dispatch). Per-domain glue is essentially identical. The deciding factor - wasn't performance: -
- -sandbox/call_service anyway
- for service mirroring. Option B reuses that one channel; Option A
- would have added a parallel entity-only RPC alongside it. Smaller
- protocol surface beats a couple-tenths of a millisecond.
- services.async_call,
- so any per-entity dispatch logic, response data
- (supports_response), and integration-specific behaviour
- executes exactly as it would locally — not a reimplementation on
- main.
-
- EntryRunner rebuilds a ConfigEntry from the
- sandbox/entry_setup payload, fetches the integration's code
- if needed (see below), drops the entry into the sandbox's
- ConfigEntries, and runs the integration's
- async_setup_entry. The integration adds entities the normal
- way — EntityBridge listens for
- EVENT_STATE_CHANGED on the sandbox's bus and, on each
- entity's first appearance, pushes
- sandbox/register_entity to main with:
-
entry_id, domain,
- sandbox_entity_id
- unique_id, name, icon,
- has_entity_name
- entity_category, device_class,
- supported_features
- capability_attributes
- (supported_color_modes, color temp range, …)
- state + attributes
- Subsequent updates push sandbox/state_changed — state
- + attributes only, no re-registration.
-
- Sandboxes hold no persistent state: config arrives on
- entry_setup, storage routes to main, and the last stateful
- bit — the integration code — is fetched at startup.
- EntrySetup.integration_source carries
- {kind: "builtin"} (a no-op; the bundled
- homeassistant package provides it) or
- {kind: "git", url, ref, …} for custom (HACS)
- integrations. Main pins ref to an exact commit sha; the
- sandbox downloads the codeload tarball before async_setup
- and extracts it into
- <config>/custom_components/<domain>, cached for
- the process lifetime by (url, ref). Core stays
- HACS-agnostic via a registered resolver hook
- (async_register_sandbox_source_resolver); a custom domain
- with no resolver fails loudly.
-
- SandboxBridge receives register_entity,
- instantiates a domain-specific proxy from entity/, and
- attaches it to the matching EntityComponent via the new
- EntityComponent.async_register_remote_platform core hook
- (Phase 5's sole core change). The proxy holds a cached state +
- attributes dict fed by state_changed; state,
- available, and per-domain typed properties
- (is_on, brightness, hs_color,
- …) read from the cache.
-
- Main always resolves the target. The proxy entities
- live in main's registries, so when a user calls
- light.turn_on against an area or label, it is main's
- service layer that expands it to a concrete entity-id list and invokes
- async_turn_on on each proxy. Those proxy calls translate
- into sandbox/call_service RPCs via a per-loop-tick batcher
- (_CallServiceBatcher) that coalesces matching
- (domain, service, service_data) calls into one multi-entity
- RPC — so a 200-light area call pays one RPC, not
- 200. The sandbox never re-resolves areas or labels; it only ever
- receives concrete entity ids and dispatches to its real entities.
-
- Exception translation maps sandbox-side vol.Invalid →
- TypeError and ServiceNotFound /
- ServiceValidationError →
- HomeAssistantError, so callers on main see the local-entity
- error shape rather than a raw remote error.
-
ai_task._resolve_attachments fetches bytes and
- writes temp files), and by the time it dispatches into the entity, the
- kwargs no longer satisfy the original service schema. Both bridge
- options are intercepting at the wrong layer.
- Resolution: ai_task and
- image join ALWAYS_MAIN; a
- service-handler-level interception story is queued for a future spec.
-
- Phase 5 shipped four canonical proxies (light,
- switch, sensor, binary_sensor) as
- the reference implementations of SandboxProxyEntity;
- Phase 13 followed up with the remaining 28 supported domains using
- the same pattern. Unknown-domain registrations fall back to the generic
- SandboxProxyEntity — state + attributes work;
- domain-typed properties don't.
-
- Beyond entity service calls, integrations register their own domain
- services (media_player.snapshot, zha.permit,
- …) and fire their own domain events (zha_event,
- mqtt_message_received). Both have to reach main, but only
- for integrations that have actually been set up.
-
- Once a sandboxed integration's async_setup_entry succeeds,
- EntryRunner adds the entry's domain to a refcounted
- ApprovedDomains set. EntityBridge also adds
- the domain of each registered entity (so a sandbox that hosts a
- light integration approves the light domain by
- virtue of registering light entities). ServiceMirror and
- EventMirror consult this set before forwarding anything
- — everything else stays inside the sandbox.
-
- Listens on the sandbox bus for EVENT_SERVICE_REGISTERED /
- EVENT_SERVICE_REMOVED and pushes
- sandbox/register_service /
- unregister_service (with supports_response) to
- main. Schemas aren't serialised — the sandbox
- owns the real schema and runs validation when the call lands on its
- services.async_call. Main installs a thin forwarder that
- ships each call back over the shared
- sandbox/call_service channel, reusing the Phase 5
- exception translator.
-
light.turn_on registered by the host
- light EntityComponent for our proxy entities
- keeps its dispatch role for entity services. The mirror only installs
- handlers for genuinely new domain-services the sandbox owns.
-
- Uses a MATCH_ALL listener with an internal-events deny-list
- and forwards only <approved_domain>_* events (e.g.
- zha_event, mqtt_message_received) via
- sandbox/fire_event. Main re-fires each on its own bus so
- automation listeners react as if the integration ran
- locally.
-
- The sandbox's context_id is on the wire but main does
- not honour it on the re-fire (a fresh local
- Context is used) — carrying a richer
- Context shape across the bridge is future work.
-
- Each sandbox group runs against a dedicated system user. The manager - hands the subprocess a plain system-user access token, freshly minted - from that user's refresh token on every spawn. There is - no scope restriction on the token today. -
- -RefreshToken.scopes field plus a
- websocket-dispatcher _scope_allows check that rejected any
- command outside {"sandbox/", "auth/current_user"} with
- ERR_UNAUTHORIZED. But the sandbox never opened a websocket
- back to main, so no code path ever exercised the check end-to-end
- — it guarded a non-existent attack surface.
- plan-strip-auth-scopes reverted the whole mechanism from
- core HA (four files: auth/models.py,
- auth/__init__.py, auth/auth_store.py,
- websocket_api/connection.py). With no WS path open in
- either direction, the sandbox token's reach is the same as v1's; the
- posture is unchanged in practice.
-
- When the sandbox→main websocket transport actually lands, scope
- enforcement is a green-field redesign with a real consumer in hand. The
- prior thinking — the optional-field-on-RefreshToken
- decision, the prefix-grant + exact-match grammar, and why a token
- subclass was rejected — is preserved in
- docs/auth-scoping-decision.md (marked SUPERSEDED) as the
- starting point for that work.
-
- Independently of the token's reach, data sharing into the
- sandbox is a positive opt-in. SandboxGroupConfig ships
- three knobs:
-
| Group | -share_states |
- share_entity_registry |
- share_areas |
-
|---|---|---|---|
| main | -True | -True | -True | -
| built-in | -True | -True | -True | -
| custom | -False | -False | -False | -
- The CLI accepts matching --share-* flags; the runtime
- stores them on a SharingConfig dataclass.
-
sharing.share_states) and the matching filtering on main's
- emit path are owed in the same PR.
- current_sandbox
- Integrations persist state via
- homeassistant.helpers.storage.Store. If a sandboxed
- integration writes my_integration.config, where does that
- file actually end up — in the sandbox's tempdir, or in main's
- .storage/?
-
- The answer: always main. The sandbox owns code; main - owns state. -
- -
- Store reads a current_sandbox
- ContextVar (declared in
- homeassistant/helpers/sandbox_context.py) at IO time. When
- it is set, Store._async_load_data,
- Store._async_write_data, and
- Store.async_remove delegate to the contextvar's
- SandboxBridge instead of touching local disk —
- talking to main via sandbox/store_load,
- sandbox/store_save, sandbox/store_remove.
- Branching at _async_write_data (rather than
- async_save) is deliberate: async_save,
- async_delay_save, and the
- EVENT_HOMEASSISTANT_FINAL_WRITE flush all funnel through
- _async_handle_write_data →
- _async_write_data, so one branch covers every write path.
- The migration loop in _async_load_data runs unchanged
- whether the wrapped envelope came from disk or the bridge.
-
- ChannelSandboxBridge
- (hass_client/sandbox_bridge.py) implements the three
- SandboxBridge store methods over the channel.
- SandboxRuntime.run does
- current_sandbox.set(ChannelSandboxBridge(channel)) right
- after the channel opens and before the warm-load or any per-runner
- handler registers, so every coroutine the runtime spawns inherits it
- (asyncio copies the context at create_task time). One
- sandbox process hosts one sandbox group, so a single bridge per runtime
- is correct. This replaced the Phase 8 module-level
- Store rebinding — a declared core HA hook rather than
- a monkey-patch, and it reaches helpers like
- restore_state that captured the original
- Store reference at import.
-
- Each SandboxBridge owns a
- _SandboxStoreServer pinned to
- <config>/.storage/sandbox/<group>/. Writes use
- util.file.write_utf8_file_atomic — the same primitive
- Store itself uses, so atomicity and durability are
- unchanged. Scope isolation is by construction: each bridge owns one
- channel for one group; forging a cross-group call would require forging
- the channel. Key validation (_require_key) rejects
- /, \, NUL, ., ..,
- and any ..-prefixed key before any path is constructed.
-
- Registries (entity/device/area/auth) that load during the sandbox's
- startup before the channel is up keep their local
- tempdir backing. Routing the HA-internals Stores too is a
- larger decision that hasn't been made yet.
-
- The proof that the bridge doesn't regress integration behaviour is the - compat lane: run HA Core's existing per-integration test suites with - sandbox wired in and watch the pass rate. -
- -| Plugin | -Wire | -When to use | -
|---|---|---|
hass_client.testing.pytest_plugin |
-
- In-memory channel pair; SandboxRuntime as an asyncio
- task
- |
- Fast feedback; freezer-safe | -
hass_client.testing.conftest_sandbox |
-
- Real stdio protobuf channel (python -m hass_client.sandbox)
- |
- Pins the subprocess boundary; freezer tests auto-skip | -
- Both share the same manager-side SandboxBridge code path;
- the only thing that differs is how the channel pair is materialised. The
- in-memory plugin is the default for fast feedback; the subprocess plugin
- is what gives us confidence the boundary really is a boundary.
-
- run_compat.py drives either plugin against a list of
- integration test directories, parses pytest's summary line, and writes
- COMPAT.csv + COMPAT.md. Per-failure output
- lands in ${SANDBOX_ERRORS_DIR:-/tmp/sandbox_errors}.
- run_compat_full.py drives it across all 807
- classifier-routable, config-entry-based integrations and captures JUnit
- per-test; categorize_failures.py buckets every failure into
- a category for triage.
-
- What's left in the failure bucket is almost entirely test-side
- residuals: ~30 diagnostic snapshots showing
- + 'sandbox': 'built-in' (fix:
- pytest --snapshot-update per integration) and ~70
- created_at snapshot drifts (fix: integration-side
- freezegun, or an optional clock-pinning fixture on the
- compat plugin). Zero bridge bugs remain in the swept
- set.
-
- The bridge landed in 17 phases over several months. Each phase had a
- single concrete deliverable, a status doc
- (status/STATUS-phase-N.md) explaining what it actually
- shipped, and explicit notes on what it deferred forward. The status
- files are the authoritative record — this is the abridged tour.
-
- Empty HA integration loads; subprocess entrypoint exists; CI green. - Nothing functional yet — just the scaffolding both sides can - build into. -
-
- Two in-process HomeAssistant instances joined by a JSON
- transport; benchmarked Option A vs Option B head-to-head
- on a 100-light area call. Recommendation: Option B.
- Side-effect: discovered the non-idempotent service handler problem,
- which pushed ai_task and image into
- ALWAYS_MAIN.
-
- classify(integration). Pure function from manifest +
- platform inspection to a group assignment — no user config, no
- per-integration migration.
-
- SandboxManager spawns one subprocess per group lazily;
- restart-on-crash with a 3/60 s budget; Ready-frame
- handshake.
-
- New flows run inside the sandbox; main owns the canonical
- ConfigEntry store. Introduced
- ConfigEntries.router and the
- SandboxFlowProxy.
-
- Four canonical proxies ship (light,
- switch, sensor,
- binary_sensor); per-loop-tick fan-out batching;
- exception translation. The other 28 domain proxies deferred to
- Phase 13 as mechanical follow-ups. Sole core change:
- EntityComponent.async_register_remote_platform.
-
- Sandbox-side ServiceMirror + EventMirror;
- refcounted ApprovedDomains set; main-side forwarder
- reuses Phase 5's call_service channel and
- exception translator.
-
- RefreshToken.scopes + dispatcher enforcement; per-group
- system user; SandboxGroupConfig with the three sharing
- knobs. The riskiest phase from a security-review angle —
- written up at length in docs/auth-scoping-decision.md.
- The scopes mechanism was later reverted
- (plan-strip-auth-scopes) because no consumer ever
- opened the connection it guarded; the per-group system-user token
- stays.
-
- The current_sandbox contextvar routes every
- Store(…) in the sandbox to
- <config>/.storage/sandbox/<group>/<key>
- on main. Store reads the contextvar at IO time —
- no module-level rebinding. (Phase 8 shipped this as a storage-module
- monkey-patch; plan-sandbox-context replaced it with the declared
- hook.)
-
- Sandboxes unload entries and dump RestoreEntity state
- into the shutdown reply; main persists it for the next boot's
- warm-load.
-
- Two pytest plugins (in-process + real-subprocess) plus
- run_compat.py.
-
- OVERVIEW.md, the auth scoping decision write-up, the
- per-phase STATUS files, this site's predecessor.
-
- Worked around the specific reader-deadlock in the shutdown reply - path. Full "one task per inbound call" fix is still owed — - remains an open follow-up. -
-
- The 28 mechanical wrappers around
- SandboxProxyEntity — climate,
- cover, media_player, lock,
- fan, vacuum, … Query-shaped RPCs
- (calendar, todo, weather)
- return empty lists pending a follow-up.
-
- ConfigEntries.async_unload consults
- router.async_unload_entry; assorted marshalling
- extensions and perf passes on the batcher.
-
- Triaged the 33-integration smoke list end-to-end, every non-pass row - investigated. Result: 99.97 % on the focused lane — the - bridge is behaviour-equivalent on the integrations users hit first. -
-
- Ran every classifier-routable, config-entry-based integration (807
- of them, 34 378 tests). Bucketed every failure with
- categorize_failures.py. Headline at this point:
- 98.07 % test pass rate, with 552 of the 664 failures traceable
- to a single root cause — the autotag's
- __sandbox_group key in
- entry.data perturbing fixtures and snapshots.
-
ConfigEntry.sandbox first-class field
- Moved the routing tag off entry.data onto a new
- first-class ConfigEntry.sandbox: str | None field. The
- pivot: original plan was to
- async_update_entry(sandbox=…) after the
- framework created the entry — didn't work because
- async_add invokes async_setup inside its
- own body, before any after-hook fires. Real fix: extend
- ConfigFlowResult with a sandbox key (same
- plumbing as minor_version / options /
- subentries) and read it at the entry constructor.
- Single fix; 552 of the 664 known failures closed.
- New headline: 99.67 % test pass rate.
-
- These are the items the per-phase STATUS files flagged forward as - explicit non-goals for the first pass. They're tracked separately so the - core bridge stayed reviewable. -
- -share_states=True subscription consumer + main-side
- filtering
- - The config knob is wired and the locked-down posture is enforced - trivially today (no subscription code exists). The consumer that - opens a subscription back to main and the filtering on main's emit - path are owed in the same PR. -
-
- A handler that issues channel.call(…) re-enters
- the reader and can deadlock. Phase 9 worked around the specific
- case; the general fix is one task per inbound call so the reader can
- keep draining the wire. Also unblocks firing
- EVENT_HOMEASSISTANT_FINAL_WRITE on sandbox shutdown.
-
data_schema serialisation across the flow wire
- Without it, frontend forms for sandboxed flows can't render argument
- hints. voluptuous_serialize-based bridge is the obvious
- shape. Pair it with the service-mirror schema serialisation in the
- same PR.
-
unique_id propagation through
- SandboxFlowProxy
-
- Small marshalling extension to _marshal_result. The
- framework's duplicate detection on main misses sandboxed flows until
- this lands.
-
calendar / todo /
- weather query-shaped RPCs
-
- The Phase 13 proxies return empty lists for
- async_get_events, todo_items, and
- weather.async_forecast_* because the action-call
- channel can't express server-side queries. Add a query-shaped RPC if
- the compat sweep ever surfaces an integration that needs them.
-
ai_task,
- image, …)
-
- Punted to ALWAYS_MAIN for now. A future spec on
- service-handler-level interception or sandbox-aware integration
- hooks is the long-term fix. The Phase 1 spike doc has the full
- write-up.
-
- Phase 17's BACKLOG.md documents two test-side
- residuals: ~30 diagnostic snapshots that show
- + 'sandbox': 'built-in' (fix:
- --snapshot-update per integration) and ~70
- created_at snapshot drifts (fix: integration-side
- freezegun, or a ~30 LOC clock-pinning fixture on the
- compat plugin).
-
- Per-phase status/STATUS-phase-N.md files are the
- authoritative record of what each phase actually built. For a quick map
- of the codebase:
-
| Concern | -HA Core side | -Sandbox side | -
|---|---|---|
| Classifier | -components/sandbox/classifier.py |
- — | -
| Lifecycle | -components/sandbox/manager.py |
-
- hass_client/sandbox.py,
- hass_client/sandbox/__main__.py
- |
-
| Channel | -components/sandbox/channel.py |
- hass_client/channel.py |
-
| Config flow | -
- components/sandbox/router.py,
- proxy_flow.py
- |
- hass_client/flow_runner.py |
-
| Entity bridge | -
- components/sandbox/bridge.py, entity/
- |
-
- hass_client/entry_runner.py,
- entity_bridge.py
- |
-
| Service / event mirror | -components/sandbox/bridge.py |
-
- hass_client/service_mirror.py,
- event_mirror.py, approved_domains.py
- |
-
| Auth | -
- components/sandbox/auth.py
- (plain system-user token)
- |
- — | -
| Store routing | -
- components/sandbox/bridge.py
- (_SandboxStoreServer),
- helpers/sandbox_context.py,
- helpers/storage.py
- |
- hass_client/sandbox_bridge.py |
-
| Shutdown | -
- components/sandbox/__init__.py
- (_on_stop), manager.py
- |
-
- hass_client/sandbox.py
- (_run_graceful_shutdown)
- |
-
| Test infra | -— | -
- hass_client/testing/, run_compat.py
- |
-
- The bridge touches three HA Core files. Each is intentional, small, and - was introduced by a specific phase: -
-homeassistant/config_entries.py —
- ConfigEntries.router attribute and
- ConfigEntryRouter protocol (Phase 4);
- async_unload consults
- router.async_unload_entry (Phase 14);
- ConfigEntry.sandbox: str | None field plumbed through
- ConfigFlowResult and
- async_finish_flow (Phase 17).
- homeassistant/helpers/entity_component.py
- —
- EntityComponent.async_register_remote_platform
- (Phase 5).
- # HA Core side
-uv run pytest tests/components/sandbox/ --no-cov -q
-
-# Client side (separate uv env — does NOT accept --no-cov)
-uv run pytest sandbox/hass_client/ -q
-
-# Compat lane
-cd sandbox && python run_compat.py
-
-
-
-