From 724626911806bfdf4d9d249eba6bd947153ac14e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 Jun 2026 07:28:45 -0400 Subject: [PATCH] sandbox/docs: document translation forwarding; fix OVERVIEW drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- sandbox/ARCHITECTURE.md | 46 ++++++++++++++++++++--- sandbox/OVERVIEW.md | 82 +++++++++++++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 17 deletions(-) diff --git a/sandbox/ARCHITECTURE.md b/sandbox/ARCHITECTURE.md index e91c5f9f60c7..25c2fceffc17 100644 --- a/sandbox/ARCHITECTURE.md +++ b/sandbox/ARCHITECTURE.md @@ -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/.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. diff --git a/sandbox/OVERVIEW.md b/sandbox/OVERVIEW.md index ef8c14943d33..00523d416bae 100644 --- a/sandbox/OVERVIEW.md +++ b/sandbox/OVERVIEW.md @@ -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 `/.storage/sandbox//` | | 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/.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) |