sandbox/docs: document translation forwarding; fix OVERVIEW drift

Both docs now describe the translation-forwarding subsystem in the body,
not just the goal: live pull (sandbox/get_translations RPC + provider
overlay) and the picker catalog hook.

- OVERVIEW: add a Translation forwarding section + "where to look" row +
  v1-diff row. Fix pre-existing drift: ALWAYS_MAIN is 24 entries across
  three groups (was listed as 6), failed-sandbox setup is SETUP_ERROR
  (not SETUP_RETRY), and the manager runs no periodic ping loop.
- ARCHITECTURE: add §11 Translation forwarding (renumber following
  sections), list translation.py/catalog.py in §2, and correct the core
  touch surface from three to five hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:23 -04:00
co-authored by Claude Opus 4.8
parent d049ab23f9
commit 7246269118
2 changed files with 111 additions and 17 deletions
+41 -5
View File
@@ -38,6 +38,8 @@ wipe-and-restart safe and could run anywhere, including a fresh container.
| `SandboxBridge` (per group) | Owns the proxy-entity registry, forwards entity service calls, re-fires sandbox events, and runs the per-group store server. |
| `classifier.py` | Pure function `Integration → SandboxAssignment` deciding which group (or main) an integration belongs to. |
| `sources.py` | The integration-source resolver registry (how custom code is located). |
| `translation.py` | `SandboxTranslationProvider` — pulls a sandboxed integration's translation strings from the live sandbox into main's translation cache (see §11). |
| `catalog.py` | Re-exports the loader catalog hook so HACS can make a sandbox-only custom integration discoverable + named in the add-integration picker (see §11). |
### Sandbox side — `sandbox/hass_client/`
@@ -198,7 +200,7 @@ primitive is injected so tests never hit the network.
> **Known runtime gap:** custom integrations that ship Python dependencies need
> `async_process_requirements` (pip) plus network egress (GitHub + PyPI) at
> setup. The wire + fetch are shipped and tested; the pip/egress runtime is
> provided by the Docker image (§11) but not yet exercised end-to-end.
> provided by the Docker image (§13) but not yet exercised end-to-end.
## 8. Entity bridge, services & events
@@ -288,16 +290,50 @@ Opt-in data sharing (state stream, entity/area registry) into the sandbox is a
future feature; the locked-down default (everything off) stands, with the
design in [`docs/design-share-states.md`](docs/design-share-states.md).
## 11. Core HA touch surface
## 11. Translation forwarding
The sandbox is deliberately small against core HA — three surfaces, each a
A sandboxed integration's frontend strings (entity names, entity-state
translations, config / options-flow labels, selectors, services, exceptions,
issues) live in its `translations/<lang>.json`, keyed by domain. Main serves
them to the frontend, but the integration runs in the sandbox — so a custom
integration's strings would otherwise silently resolve to `{}`
(`async_get_integrations` returns `IntegrationNotFound` as a dict value, which
the translation cache skips). Two seams close the gap:
- **Live pull (sandbox running).** A declared core hook
(`async_register_sandbox_translation_provider` in `helpers/translation.py`)
lets `_TranslationCache` overlay a provider's strings onto the per-language
set *before* flattening, so they share the same English-fallback + cache
machinery as disk strings. The component's `SandboxTranslationProvider`
resolves a domain's group (a loaded entry's `ConfigEntry.sandbox`, or an
in-progress flow's `SandboxFlowProxy.sandbox_group`), **carves out built-ins**
(main reads its own identical disk copy — the RPC is custom-only), batches the
rest into one `sandbox/get_translations` RPC per group/language, and
**degrades to empty** on a dead/slow channel so the cache lock never wedges
the frontend. The sandbox handler reuses core's string loader and pre-fills
`title` from `integration.name` (main can't — it has no `Integration` for a
custom). `async_invalidate_translations` evicts a domain's strings on entry
reload, so a HACS update at a new `ref` re-pulls.
- **Picker (no sandbox running).** A separate, display-only catalog hook
(`async_register_sandbox_catalog_provider` in `loader.py`, re-exported via
`catalog.py`) lets HACS contribute `{domain, name, …, title_translations?}`
entries that `async_get_integration_descriptions` merges into the
add-integration dialog — so a sandbox-only custom is discoverable and named
without spawning its sandbox. Kept separate from the sha-pinned source
resolver; `title` degrades to `name`.
## 12. Core HA touch surface
The sandbox is deliberately small against core HA — five surfaces, each a
declared public hook rather than a reach into private internals:
- `config_entries.py` — the `router` attribute + `ConfigEntryRouter` Protocol (three call sites) and the first-class `ConfigEntry.sandbox` field.
- `helpers/entity_component.py` — `EntityComponent.async_register_remote_platform`, so a sandbox-built `EntityPlatform` attaches without re-discovering the local integration.
- `helpers/sandbox_context.py` (new) + `helpers/storage.py` — the `current_sandbox` ContextVar + `SandboxBridge` Protocol read by `Store`'s IO methods.
- `helpers/translation.py` — `async_register_sandbox_translation_provider` + the `_TranslationCache` overlay and `async_invalidate_translations` (§11).
- `loader.py` — `async_register_sandbox_catalog_provider` + the catalog merge in `async_get_integration_descriptions` (§11).
## 12. Testing & containerisation
## 13. Testing & containerisation
Two pytest plugins under `hass_client/testing/` let HA Core's per-integration
suites run with the sandbox wired in; both share the manager-side
@@ -312,7 +348,7 @@ transport (a same-host compose harness is templated; full remote operation
waits on the websocket transport). See
[`hass_client/docs/docker.md`](hass_client/docs/docker.md).
## 13. Out of scope / future work
## 14. Out of scope / future work
- **WebSocket transport** — the seam is ready; lands with the share-states connection work.
- **State-sharing opt-in consumer** + main-side filtering ([`docs/design-share-states.md`](docs/design-share-states.md)); would let the lockdown helpers (§3) return to sandboxes.
+70 -12
View File
@@ -50,6 +50,7 @@ inside the sandbox.
| Store routing | None — sandbox writes to its own tempdir | The `current_sandbox` contextvar makes `Store` IO proxy to main; main writes to `<config>/.storage/sandbox/<group>/<key>` |
| Shutdown | Best-effort | Graceful `sandbox/shutdown` round-trip; sandbox unloads entries + dumps `RestoreEntity` state; main persists it for next boot |
| Custom integrations | Out of scope | First-class — they route to the `custom` group |
| Translations | Not forwarded — a sandboxed integration's frontend strings never reached main | Pulled on demand over `sandbox/get_translations` and overlaid into main's translation cache; a display-only catalog hook covers the not-yet-running picker case |
The design choices and the failure modes of v1 they fix are recorded in
[`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md) and
@@ -108,12 +109,23 @@ Rule order (first match wins):
1. `integration_type == "system"` → **main**. System integrations are
part of the HA runtime; sandboxing them is meaningless.
2. `domain in ALWAYS_MAIN` → **main**. Hand-picked deny-list:
`script`, `automation`, `scene`, `cloud`, `ai_task`, `image`. Each
entry has an inline "why" in [`const.py`](../homeassistant/components/sandbox/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 — see the spike doc.
2. `domain in ALWAYS_MAIN` → **main**. A 24-entry deny-list, each with an
inline "why" in [`const.py`](../homeassistant/components/sandbox/const.py),
in three groups:
- **Behavioural punts** — `script`, `automation`, `scene`, `cloud`, plus
`ai_task` and `image`. The latter two do non-idempotent pre-dispatch work
(attachment / byte resolution) that neither bridge option intercepts
cleanly — see the Phase 1 spike doc.
- **Broad readers** — `template`, `group`, `homekit` read *all* entities /
registries (Jinja `states()`, `hass.states.async_all()`), so they can't be
narrowly scoped and break under sandbox lockdown.
- **Source-entity helpers** — `min_max`, `statistics`, `trend`, `threshold`,
`derivative`, `integration`, `utility_meter`, `filter`, `mold_indicator`,
`bayesian`, `generic_thermostat`, `generic_hygrostat`, `switch_as_x`,
`history_stats`, `proximity` each read a declared set of *foreign*
entities (and sometimes the registries). They stay on main until the
share-states consumer lands a scoped declared-source-entity allow-list
([`docs/design-share-states.md`](docs/design-share-states.md)).
3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main**: `stt`,
`tts`, `conversation`, `assist_satellite`, `wake_word`, `camera`.
These exchange audio/byte streams the JSON channel can't ferry.
@@ -171,13 +183,17 @@ transports. The three-layer split is `Channel` (dispatch core) → `Codec`
Restart-on-crash is bounded: 3 attempts within a 60s 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.
`SandboxFailedError` — the router catches it in `async_setup_entry` and
marks the affected entries `SETUP_ERROR`
([`router.py`](../homeassistant/components/sandbox/router.py)).
(`SETUP_RETRY` is reserved for a narrower case — a `ChannelClosedError`
*during* an `entry_setup` round-trip, where a retry can succeed.)
A `sandbox/ping` handler is registered and exercised by the
subprocess test (`test_phase4_subprocess`); the periodic 30s ping loop
is wired through but currently disabled (process-exit detection covers
the hard-crash case).
A `sandbox/ping` handler is registered on the sandbox side and exercised
by the subprocess test (`test_phase4_subprocess`), but the manager runs
**no periodic ping loop** — liveness relies on process-exit detection,
which covers the hard-crash case. An active health-ping is a possible
future addition.
### Graceful shutdown
@@ -407,6 +423,47 @@ domain by virtue of registering light entities). `ServiceMirror` and
`parent_id` / `user_id` for an id it issued or minting a fresh
`user_id=None` `Context` (with main's own id) otherwise.
## Translation forwarding
A sandboxed integration's frontend strings — entity names, entity-state
translations, config / options-flow labels, selectors, services, exceptions,
issues — live in its `translations/<lang>.json`, keyed by integration domain.
Main serves them to the frontend, but the integration runs in the sandbox, so
without help a custom integration's strings silently resolve to `{}`
(`async_get_integrations` returns `IntegrationNotFound` *as a dict value*; the
translation cache skips it). Two seams close the gap:
- **Live pull (sandbox running).** `homeassistant/helpers/translation.py` grows
a declared hook, `async_register_sandbox_translation_provider`;
`_TranslationCache` overlays the provider's result onto the per-language
strings *before* flattening, so sandboxed strings flow through the same
English-fallback + cache machinery as disk strings. The sandbox component's
[`translation.py`](../homeassistant/components/sandbox/translation.py)
`SandboxTranslationProvider` resolves each domain's owning group (a loaded
entry's `ConfigEntry.sandbox`, or an in-progress flow's
`SandboxFlowProxy.sandbox_group`), **carves out built-ins** (main reads its
own byte-identical disk copy — the RPC is only for customs), batches the rest
into one `sandbox/get_translations` RPC per group/language, and **degrades to
empty** on a dead/slow channel (5s timeout) so the cache lock never wedges the
frontend. The sandbox handler (`hass_client/sandbox/__init__.py`,
`_handle_get_translations`) reuses core's string loader and **pre-fills
`title`** from `integration.name` — main can't, holding no `Integration` for a
custom. `async_invalidate_translations` (the first translation-cache eviction
API) drops a domain's cached strings on entry reload, so a HACS update at a
new `ref` re-pulls fresh strings.
- **Picker (no sandbox running).** The add-integration dialog needs only the
`title` string and must work cold, but a sandbox-only custom integration isn't
on main's disk at all — it isn't even *discoverable*. A separate, display-only
catalog hook — `async_register_sandbox_catalog_provider` in
[`loader.py`](../homeassistant/loader.py), re-exported via the sandbox
component's [`catalog.py`](../homeassistant/components/sandbox/catalog.py) —
lets HACS contribute `{domain, name, …, title_translations?}` entries that
`async_get_integration_descriptions` merges into the picker. It is kept
deliberately separate from the security-critical, sha-pinned integration-source
resolver; `title` degrades to the catalog `name` when no translations are
indexed.
## Sandbox auth & opt-in data sharing
The sandbox is **not an authenticated principal inside main.** It never
@@ -609,6 +666,7 @@ actually built, what it deferred, and what it flagged forward. For a quick map:
| Service/event mirror | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) | [`service_mirror.py`](hass_client/hass_client/service_mirror.py), [`event_mirror.py`](hass_client/hass_client/event_mirror.py), [`approved_domains.py`](hass_client/hass_client/approved_domains.py) |
| Context restoration | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) (`_remember_context` / `_resolve_context`, TTL cache) | — |
| Store routing | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) (`_SandboxStoreServer`), `homeassistant/helpers/sandbox_context.py`, `homeassistant/helpers/storage.py` | [`sandbox_bridge.py`](hass_client/hass_client/sandbox_bridge.py) |
| Translations | [`translation.py`](../homeassistant/components/sandbox/translation.py), [`catalog.py`](../homeassistant/components/sandbox/catalog.py), `homeassistant/helpers/translation.py`, `homeassistant/loader.py` | [`sandbox.py`](hass_client/hass_client/sandbox/__init__.py) (`_handle_get_translations`) |
| Shutdown | [`__init__.py`](../homeassistant/components/sandbox/__init__.py) (`_on_stop`), `manager.py` | [`sandbox.py`](hass_client/hass_client/sandbox/__init__.py) (`_run_graceful_shutdown`) |
| Test infra | — | [`testing/`](hass_client/hass_client/testing/), [`run_compat.py`](run_compat.py) |