fix: a channel-less profile clone drops Home Assistant identity while its plugin is absent
Profile cloning learns which env keys and config blocks belong to a messaging channel from core's platform registry. Moving Home Assistant into a plugin left that ownership only on the loaded plugin, so while the migration is pending (catalog miss, offline, lazy installs off, declined), `hermes profile create X --clone` kept HASS_TOKEN/HASS_URL and the gateway.homeassistant block in the clone while printing "Messaging channels were NOT cloned (homeassistant)". The copied token then reads as Home Assistant in use, so the clone would migrate onto the source's HA identity. LEFT_CORE rows now declare the channel ownership core had for their platform: enable_env (the env that enabled the adapter) and channel_env_prefixes (prefixes shared with the plugin's tools). profile_channels includes those platforms in platform_ids, the shared-prefix rule, the enable-gate and the duplicate-credential inventory regardless of plugin installation, so the source-runs-the- adapter rule is exactly core's: enabled (config or HASS_TOKEN) -> stripped; explicitly disabled, tool-only -> HASS_* kept as a tool credential.
This commit is contained in:
@@ -202,7 +202,7 @@ islands by design — no live config inheritance; `--clone` copies at creation,
|
||||
channels (`profile_channels.py`: ownership-based inventory evaluated in the SOURCE's plugin scope —
|
||||
adapter-declared keys + canonical/alias prefixes + `GATEWAY_ALLOW*`/`GATEWAY_RELAY_*`; prefixes shared
|
||||
with tools (`TWILIO_`/`EMAIL_`, plus a plugin platform's `shared_env_prefixes`) are stripped only when the source runs that adapter; never a hand
|
||||
list). `--clone-channels` opts in and its live-multiplexer refusal lives in `create_profile` (CLI, REST
|
||||
list; a platform that left core keeps its ownership from its `LEFT_CORE` row while the plugin is absent). `--clone-channels` opts in and its live-multiplexer refusal lives in `create_profile` (CLI, REST
|
||||
and TUI all go through it). Clones are built in `profiles/.<name>.staging-<pid>` (hidden → invisible to
|
||||
`_iter_named_profile_dirs` and the hot-serve rescan) and published by one `os.rename` after the strip;
|
||||
symlinked `.env`/`config.yaml` are materialized first so a clone never writes through to its source. Multiplex
|
||||
|
||||
@@ -112,6 +112,12 @@ class LeftCoreFeature:
|
||||
off_platforms: tuple[str, ...] = ()
|
||||
# Gateway platform the plugin ships: a running gateway serves it only after a restart.
|
||||
platform: str = ""
|
||||
# Channel ownership core declared for that platform, kept while the plugin is absent (migration
|
||||
# pending, declined, failed) so a channel-less profile clone still strips the source's identity
|
||||
# (hermes_cli/profile_channels.py): the env whose presence enabled the adapter, and the env
|
||||
# prefixes it shared with the feature's tools (stripped only when the source runs the adapter).
|
||||
enable_env: tuple[str, ...] = ()
|
||||
channel_env_prefixes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
LEFT_CORE: tuple[LeftCoreFeature, ...] = (
|
||||
@@ -120,6 +126,7 @@ LEFT_CORE: tuple[LeftCoreFeature, ...] = (
|
||||
unchanged="HASS_TOKEN/HASS_URL, platforms.homeassistant and the ha_* tool names are unchanged",
|
||||
secret_env=("HASS_TOKEN",), private_env=("HASS_URL",),
|
||||
toolsets=("homeassistant",), off_platforms=("acp", "webhook"), platform="homeassistant",
|
||||
enable_env=("HASS_TOKEN",), channel_env_prefixes=("HASS_",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ gateways fight over one bot (standalone) or blocks ``hermes gateway migrate --mu
|
||||
duplicate-credential finding per platform.
|
||||
|
||||
The inventory is OWNERSHIP-based and evaluated in the SOURCE profile's plugin scope: every adapter
|
||||
(built-in ``Platform`` member or plugin registered under the source's ``HERMES_HOME``) owns the env
|
||||
(built-in ``Platform`` member, a platform that left core — its ``LEFT_CORE`` row, plugin installed or
|
||||
not — or a plugin registered under the source's ``HERMES_HOME``) owns the env
|
||||
keys it declares outright (``required_env``, allowlist / allow-all / home-channel names, the gateway
|
||||
env-override table ``gateway.config_env._ENV_STEPS`` / ``_ENV_ENABLE_CREDENTIALS``) plus every key
|
||||
under its canonical ``<PLATFORM>_`` prefix and its historical alias prefixes. Gateway-wide channel
|
||||
@@ -87,9 +88,18 @@ def _registry_entries() -> list:
|
||||
return []
|
||||
|
||||
|
||||
def _left_core_platforms() -> list:
|
||||
"""Rows of platforms that left core for a catalog plugin (``hermes_cli.left_core_migration``).
|
||||
Their channel ownership comes from the row, so it holds while the plugin is absent."""
|
||||
from hermes_cli.left_core_migration import LEFT_CORE
|
||||
return [feature for feature in LEFT_CORE if feature.platform]
|
||||
|
||||
|
||||
def _shared_with_tools(source_dir: Optional[Path] = None) -> Dict[str, Tuple[str, ...]]:
|
||||
"""``_SHARED_WITH_TOOLS`` plus every plugin platform's ``shared_env_prefixes`` in ``source_dir``'s scope."""
|
||||
"""``_SHARED_WITH_TOOLS``, left-core platforms' prefixes, and every plugin platform's
|
||||
``shared_env_prefixes`` in ``source_dir``'s scope."""
|
||||
shared = dict(_SHARED_WITH_TOOLS)
|
||||
shared.update({f.platform: f.channel_env_prefixes for f in _left_core_platforms() if f.channel_env_prefixes})
|
||||
with _plugin_scope(source_dir):
|
||||
for entry in _registry_entries():
|
||||
prefixes = tuple(getattr(entry, "shared_env_prefixes", ()) or ())
|
||||
@@ -99,10 +109,11 @@ def _shared_with_tools(source_dir: Optional[Path] = None) -> Dict[str, Tuple[str
|
||||
|
||||
|
||||
def platform_ids(source_dir: Optional[Path] = None) -> List[str]:
|
||||
"""Every messaging platform id: built-in ``Platform`` members plus the plugin adapters registered
|
||||
in ``source_dir``'s scope (ambient scope when ``None``)."""
|
||||
"""Every messaging platform id: built-in ``Platform`` members, platforms that left core, plus the
|
||||
plugin adapters registered in ``source_dir``'s scope (ambient scope when ``None``)."""
|
||||
from gateway.config import Platform
|
||||
ids = {m.value for m in Platform.__members__.values() if m.value != "local"}
|
||||
ids.update(feature.platform for feature in _left_core_platforms())
|
||||
with _plugin_scope(source_dir):
|
||||
ids.update(entry.name for entry in _registry_entries())
|
||||
return sorted(ids)
|
||||
@@ -150,6 +161,8 @@ def declared_channel_env_keys(source_dir: Optional[Path] = None) -> Dict[str, st
|
||||
fields, the gateway env-override table) plus gateway-wide channel policy. Prefix matching covers
|
||||
the rest."""
|
||||
keys: Dict[str, str] = dict.fromkeys(_GATEWAY_POLICY_KEYS, GATEWAY_POLICY_ID)
|
||||
for feature in _left_core_platforms():
|
||||
keys.update(dict.fromkeys(feature.enable_env, feature.platform))
|
||||
with _plugin_scope(source_dir):
|
||||
for entry in _registry_entries():
|
||||
for name in (*entry.required_env, entry.allowed_users_env, entry.allow_all_env, entry.cron_deliver_env_var):
|
||||
@@ -196,6 +209,8 @@ def credential_env_keys() -> Dict[str, str]:
|
||||
flags, URLs and hosts are excluded: two profiles pointing at one Mattermost server collide only
|
||||
when they also share the token."""
|
||||
keys: Dict[str, str] = {}
|
||||
for feature in _left_core_platforms():
|
||||
keys.update(dict.fromkeys(feature.enable_env, feature.platform))
|
||||
for entry in _registry_entries():
|
||||
keys.update(dict.fromkeys(entry.required_env, entry.name))
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -253,6 +268,8 @@ def _shared_adapters_active(source_dir: Optional[Path], shared: Dict[str, Tuple[
|
||||
from gateway import config_env
|
||||
for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items():
|
||||
creds_by_platform[platform.value] = set(names)
|
||||
for feature in _left_core_platforms():
|
||||
creds_by_platform.setdefault(feature.platform, set(feature.enable_env))
|
||||
with _plugin_scope(source_dir):
|
||||
for entry in _registry_entries():
|
||||
creds_by_platform.setdefault(entry.name, set(entry.required_env))
|
||||
|
||||
@@ -225,6 +225,27 @@ def test_ownership_inventory_strips_policy_relay_and_aliases_but_keeps_tool_cred
|
||||
assert keys == {"OPENAI_API_KEY", "TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "stripped"), [
|
||||
({"gateway": {"homeassistant": {"enabled": True, "token": "ha-short-token"}}}, True),
|
||||
({}, True), # HASS_TOKEN in .env alone enabled core's adapter
|
||||
({"platforms": {"homeassistant": {"enabled": False}}}, False), # tool-only: HASS_* is a tool key
|
||||
])
|
||||
def test_left_core_platform_keeps_channel_ownership_while_its_plugin_is_absent(home, config, stripped):
|
||||
"""Home Assistant left core; with its plugin not installed yet (migration pending, declined,
|
||||
failed) a channel-less clone still leaves the source's HA identity behind, like core did."""
|
||||
(home / ".env").write_text("OPENAI_API_KEY=sk\nHASS_TOKEN=ha-token\nHASS_URL=http://ha.local\n", encoding="utf-8")
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"model": {"default": "gpt-5", "provider": "openai"}, **config}), encoding="utf-8")
|
||||
assert not (home / "plugins" / "homeassistant").exists()
|
||||
|
||||
profile_dir = create_profile(f"ha{len(config)}{int(stripped)}", clone_config=True, no_alias=True)
|
||||
|
||||
keys = {line.split("=", 1)[0] for line in (profile_dir / ".env").read_text(encoding="utf-8").splitlines()
|
||||
if "=" in line and not line.startswith("#")}
|
||||
assert keys == ({"OPENAI_API_KEY"} if stripped else {"OPENAI_API_KEY", "HASS_TOKEN", "HASS_URL"})
|
||||
assert "ha-short-token" not in (profile_dir / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
_SHARED_PLUGIN_MANIFEST = "name: sharedplat\nkind: platform\nversion: 1.0.0\nrequires_env:\n - name: SHP_TOKEN\n password: true\n"
|
||||
_SHARED_PLUGIN_INIT = (
|
||||
"from gateway.platform_registry import PlatformEntry\n"
|
||||
|
||||
Reference in New Issue
Block a user