diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index 6cb758a15c..9ac251e0bf 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -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/..staging-` (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 diff --git a/hermes_cli/left_core_migration.py b/hermes_cli/left_core_migration.py index 4a99f9f02c..4f51738f80 100644 --- a/hermes_cli/left_core_migration.py +++ b/hermes_cli/left_core_migration.py @@ -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_",), ), ) diff --git a/hermes_cli/profile_channels.py b/hermes_cli/profile_channels.py index 11fba1d337..a0a5474c90 100644 --- a/hermes_cli/profile_channels.py +++ b/hermes_cli/profile_channels.py @@ -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 ``_`` 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)) diff --git a/tests/hermes_cli/test_profile_clone_channels.py b/tests/hermes_cli/test_profile_clone_channels.py index 90d8623d8e..efb937e819 100644 --- a/tests/hermes_cli/test_profile_clone_channels.py +++ b/tests/hermes_cli/test_profile_clone_channels.py @@ -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"