sandbox: carry the target entry into reauth/reconfigure/reset flows

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
This commit is contained in:
Paulus Schoutsen
2026-07-08 02:36:35 -04:00
co-authored by Claude Fable 5
parent 9c0d69d8f3
commit 402e7987bd
12 changed files with 308 additions and 115 deletions
File diff suppressed because one or more lines are too long
@@ -269,14 +269,16 @@ class Ready(_message.Message):
def __init__(self) -> None: ...
class FlowInit(_message.Message):
__slots__ = ("handler", "context", "data")
__slots__ = ("handler", "context", "data", "entry")
HANDLER_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
ENTRY_FIELD_NUMBER: _ClassVar[int]
handler: str
context: bytes
data: bytes
def __init__(self, handler: _Optional[str] = ..., context: _Optional[bytes] = ..., data: _Optional[bytes] = ...) -> None: ...
entry: EntrySetup
def __init__(self, handler: _Optional[str] = ..., context: _Optional[bytes] = ..., data: _Optional[bytes] = ..., entry: _Optional[_Union[EntrySetup, _Mapping]] = ...) -> None: ...
class FlowStep(_message.Message):
__slots__ = ("flow_id", "user_input")
@@ -328,6 +328,28 @@ def make_entity_description(
return msg
def entry_to_setup_proto(entry: Any) -> pb.EntrySetup:
"""Serialise the entry-identity fields of a ConfigEntry into EntrySetup.
Shared by the entry_setup payload and the flow proxy's reauth/reconfigure
entry carry — only the fields the sandbox needs to rebuild the entry
(integration_source / core_config are filled separately by entry_setup).
"""
msg = pb.EntrySetup(
entry_id=entry.entry_id,
domain=entry.domain,
title=entry.title,
data=encode_json(dict(entry.data)),
options=encode_json(dict(entry.options)),
source=entry.source,
version=entry.version,
minor_version=entry.minor_version,
)
if entry.unique_id is not None:
msg.unique_id = entry.unique_id
return msg
def core_config_to_proto(config: Any) -> pb.CoreConfig:
"""Snapshot a hass ``Config`` into the wire ``CoreConfig`` message.
@@ -42,6 +42,7 @@ from .messages import (
decode_json,
decode_json_dict,
encode_json,
entry_to_setup_proto,
)
from .schema_bridge import reconstruct_schema
@@ -174,6 +175,15 @@ class SandboxFlowProxy(ConfigFlow):
)
if user_input is not None:
request.data = encode_json(_to_jsonable(user_input))
# A reauth / reconfigure / reset flow operates on an existing
# entry that main owns. Carry a copy so the sandbox flow
# manager can resolve it — otherwise _get_reauth_entry() /
# _get_reconfigure_entry() raise UnknownEntry on the private
# hass.
if (entry_id := self.context.get("entry_id")) is not None and (
entry := self.hass.config_entries.async_get_entry(entry_id)
) is not None:
request.entry.CopyFrom(entry_to_setup_proto(entry))
result = await channel.call(MSG_FLOW_INIT, request)
self._sandbox_flow_id = (
result.flow_id if result.HasField("flow_id") else None
+2 -13
View File
@@ -33,7 +33,7 @@ from .messages import (
MSG_ENTRY_SETUP,
MSG_ENTRY_UNLOAD,
core_config_to_proto,
encode_json,
entry_to_setup_proto,
)
from .proxy_flow import SandboxFlowProxy
from .sources import SandboxSourceError, async_resolve_integration_source
@@ -249,18 +249,7 @@ async def _entry_setup_payload(
computes sun times / distances / unit conversions like main. May raise
:class:`SandboxSourceError` if a custom integration has no source resolver.
"""
msg = pb.EntrySetup(
entry_id=entry.entry_id,
domain=entry.domain,
title=entry.title,
data=encode_json(dict(entry.data)),
options=encode_json(dict(entry.options)),
source=entry.source,
version=entry.version,
minor_version=entry.minor_version,
)
if entry.unique_id is not None:
msg.unique_id = entry.unique_id
msg = entry_to_setup_proto(entry)
msg.integration_source.CopyFrom(
await async_resolve_integration_source(hass, entry.domain)
)
File diff suppressed because one or more lines are too long
@@ -269,14 +269,16 @@ class Ready(_message.Message):
def __init__(self) -> None: ...
class FlowInit(_message.Message):
__slots__ = ("handler", "context", "data")
__slots__ = ("handler", "context", "data", "entry")
HANDLER_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
ENTRY_FIELD_NUMBER: _ClassVar[int]
handler: str
context: bytes
data: bytes
def __init__(self, handler: _Optional[str] = ..., context: _Optional[bytes] = ..., data: _Optional[bytes] = ...) -> None: ...
entry: EntrySetup
def __init__(self, handler: _Optional[str] = ..., context: _Optional[bytes] = ..., data: _Optional[bytes] = ..., entry: _Optional[_Union[EntrySetup, _Mapping]] = ...) -> None: ...
class FlowStep(_message.Message):
__slots__ = ("flow_id", "user_input")
@@ -53,6 +53,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from ._proto import sandbox_pb2 as pb
from .channel import Channel
from .entry_runner import _entry_from_proto
from .messages import (
MSG_FLOW_ABORT,
MSG_FLOW_INIT,
@@ -180,11 +181,26 @@ class FlowRunner:
# dicts (the proxy flattened the *ServiceInfo / DiscoveryKey objects);
# rebuild the real types so async_step_<source> sees what it expects.
context, data = _rehydrate_discovery(context, data)
# Reauth / reconfigure / reset flows resolve the entry they operate on
# via ConfigFlow._get_reauth_entry() / _get_reconfigure_entry(), which
# look it up on this private hass. Main owns the entry, so seed a copy
# before the flow runs or those lookups raise UnknownEntry.
if msg.HasField("entry"):
self._ensure_flow_entry(msg.entry)
result = await self.hass.config_entries.flow.async_init(
msg.handler, context=context, data=data
)
return _marshal_result(result, self.hass.config_entries.flow)
def _ensure_flow_entry(self, entry_msg: pb.EntrySetup) -> None:
"""Seed the private config_entries with the flow's target entry."""
config_entries = self.hass.config_entries
if config_entries.async_get_entry(entry_msg.entry_id) is not None:
return
entry = _entry_from_proto(entry_msg)
config_entries._entries[entry.entry_id] = entry # noqa: SLF001
async def _handle_flow_step(self, msg: pb.FlowStep) -> pb.FlowResult:
user_input = (
decode_json_dict(msg.user_input) if msg.HasField("user_input") else None
@@ -328,6 +328,28 @@ def make_entity_description(
return msg
def entry_to_setup_proto(entry: Any) -> pb.EntrySetup:
"""Serialise the entry-identity fields of a ConfigEntry into EntrySetup.
Shared by the entry_setup payload and the flow proxy's reauth/reconfigure
entry carry — only the fields the sandbox needs to rebuild the entry
(integration_source / core_config are filled separately by entry_setup).
"""
msg = pb.EntrySetup(
entry_id=entry.entry_id,
domain=entry.domain,
title=entry.title,
data=encode_json(dict(entry.data)),
options=encode_json(dict(entry.options)),
source=entry.source,
version=entry.version,
minor_version=entry.minor_version,
)
if entry.unique_id is not None:
msg.unique_id = entry.unique_id
return msg
def core_config_to_proto(config: Any) -> pb.CoreConfig:
"""Snapshot a hass ``Config`` into the wire ``CoreConfig`` message.
@@ -407,3 +407,76 @@ async def test_flow_abort_is_idempotent(
# FlowAbortResult is an empty message (was `result == {}` on the dict wire).
assert isinstance(result, pb.FlowAbortResult)
assert result.SerializeToString() == b""
class _ReconfigureFlow(ConfigFlow, domain="phase4_reconfigure"):
"""Flow whose reconfigure step reads the entry it operates on."""
VERSION = 1
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
# Raises UnknownEntry on the private hass unless flow_init seeded the
# entry — the whole point of the FlowInit.entry carry.
entry = self._get_reconfigure_entry()
return self.async_show_form(
step_id="reconfigure",
description_placeholders={"title": entry.title},
)
async def test_flow_init_seeds_reconfigure_entry(
channels: tuple[Channel, Channel], runner: FlowRunner
) -> None:
"""FlowInit.entry lets a reconfigure flow resolve main's entry.
Regression for the UnknownEntry cluster: reauth/reconfigure flows call
``_get_reconfigure_entry()`` / ``_get_reauth_entry()`` which look the
entry up on the private hass. Main owns it, so flow_init must seed a copy.
"""
main, sandbox = channels
runner.register(sandbox)
main.start()
sandbox.start()
ha_config_entries.HANDLERS["phase4_reconfigure"] = _ReconfigureFlow
fake_module = ModuleType("homeassistant.components.phase4_reconfigure")
fake_flow_module = ModuleType(
"homeassistant.components.phase4_reconfigure.config_flow"
)
runner.hass.data[ha_loader.DATA_COMPONENTS]["phase4_reconfigure"] = fake_module
runner.hass.data[ha_loader.DATA_COMPONENTS][
"phase4_reconfigure.config_flow"
] = fake_flow_module
runner.hass.config.components.add("phase4_reconfigure")
try:
init_msg = pb.FlowInit(handler="phase4_reconfigure")
init_msg.context = encode_json(
{"source": "reconfigure", "entry_id": "reconf-entry"}
)
init_msg.entry.CopyFrom(
pb.EntrySetup(
entry_id="reconf-entry",
domain="phase4_reconfigure",
title="Existing Device",
data=encode_json({"host": "1.2.3.4"}),
options=encode_json({}),
source="user",
version=1,
minor_version=1,
)
)
result = await main.call("sandbox/flow_init", init_msg)
finally:
ha_config_entries.HANDLERS.pop("phase4_reconfigure", None)
runner.hass.data[ha_loader.DATA_COMPONENTS].pop("phase4_reconfigure", None)
runner.hass.data[ha_loader.DATA_COMPONENTS].pop(
"phase4_reconfigure.config_flow", None
)
assert result.type == "form"
assert result.step_id == "reconfigure"
assert decode_json_dict(result.description_placeholders) == {
"title": "Existing Device"
}
+5
View File
@@ -239,6 +239,11 @@ message FlowInit {
string handler = 1;
bytes context = 2; // dynamic, JSON object
optional bytes data = 3; // dynamic, JSON object (unset = no initial data)
// The entry a reauth / reconfigure / reset flow operates on. Main owns the
// ConfigEntry; the sandbox-private flow manager needs a copy so
// ConfigFlow._get_reauth_entry() / _get_reconfigure_entry() resolve instead
// of raising UnknownEntry. Unset for a plain user / discovery flow.
optional EntrySetup entry = 4;
}
message FlowStep {
+54 -2
View File
@@ -16,7 +16,11 @@ from homeassistant.components.sandbox.manager import SandboxManager
from homeassistant.components.sandbox.messages import decode_json_dict, encode_json
from homeassistant.components.sandbox.proxy_flow import SandboxFlowProxy
from homeassistant.components.sandbox.router import SandboxFlowRouter
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigEntryState,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.discovery_flow import DiscoveryKey
@@ -24,7 +28,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from ._helpers import FakeSandboxManager, make_channel_pair
from tests.common import MockModule, mock_integration
from tests.common import MockConfigEntry, MockModule, mock_integration
class _SandboxStub:
@@ -479,6 +483,54 @@ async def test_discovery_flow_marshals_service_info(
assert "host" not in data
async def test_reconfigure_flow_carries_entry(
hass: HomeAssistant, manager: FakeSandboxManager
) -> None:
"""A reconfigure flow ships main's entry so the sandbox can resolve it.
Regression for the UnknownEntry cluster: without ``FlowInit.entry`` the
sandbox flow's ``_get_reconfigure_entry()`` raises on the private hass.
"""
mock_integration(hass, MockModule("test_proxy_reconf"))
entry = MockConfigEntry(
domain="test_proxy_reconf",
title="Existing",
data={"host": "1.2.3.4"},
sandbox="built-in",
entry_id="reconf-1",
)
entry.add_to_hass(hass)
responses = [
pb.FlowResult(
type=FlowResultType.FORM.value,
flow_id="sandbox-reconf-1",
handler="test_proxy_reconf",
step_id="reconfigure",
),
]
with (
_wired_sandbox(manager, group="built-in", responses=responses) as stub,
patch(
"homeassistant.components.sandbox.router.classify",
return_value=type("A", (), {"is_main": False, "group": "built-in"})(),
),
):
await _install_router(hass, manager)
result = await hass.config_entries.flow.async_init(
"test_proxy_reconf",
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
assert result["type"] is FlowResultType.FORM
assert len(stub.init_calls) == 1
init = stub.init_calls[0]
assert init.HasField("entry")
assert init.entry.entry_id == "reconf-1"
assert init.entry.title == "Existing"
assert decode_json_dict(init.entry.data) == {"host": "1.2.3.4"}
async def _install_router(hass: HomeAssistant, manager: FakeSandboxManager) -> None:
"""Attach a router that uses ``manager`` to ``hass.config_entries``."""
router = SandboxFlowRouter(hass, cast(SandboxManager, manager))