mirror of
https://github.com/home-assistant/core.git
synced 2026-08-10 07:31:54 +02:00
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:
co-authored by
Claude Fable 5
parent
9c0d69d8f3
commit
402e7987bd
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user