From 047de4a668e08236bce48e8b45801da91a4e7bc5 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 2 May 2026 03:01:51 -0700 Subject: [PATCH] feat(gateway): plugin-platform-safe deserialization via resolve_platform_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, plugin-registered platforms (like molecule-a2a) survive the runtime hot path but die on daemon restart: SessionSource.from_dict calls Platform(value) directly, which raises ValueError for any name not in the closed enum. Restored sessions with plugin-platform origins either crash the restore loop or silently drop the platform field. Add resolve_platform_id() — Platform-first, falls back to PluginPlatformIdentifier ONLY when a currently-loaded plugin actually claims the name. Bare typos and corrupted state still raise so silent state-corruption can't slip past restore. Wire it into the three known from_dict call sites: - gateway/session.py:SessionSource.from_dict - gateway/session.py:SessionEntry.from_dict (drops the silent debug-log swallow that left platform=None on unknown values) - gateway/config.py:HomeChannel.from_dict All existing gateway/test_session.py round-trip tests still pass, including test_invalid_platform_raises (the narrow fallback preserves loud-fail semantics for genuinely unknown names). --- gateway/config.py | 3 ++- gateway/session.py | 9 ++++----- hermes_cli/plugins.py | 26 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 2bf2bc67..0ac10e0e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -90,8 +90,9 @@ class HomeChannel: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel": + from hermes_cli.plugins import resolve_platform_id return cls( - platform=Platform(data["platform"]), + platform=resolve_platform_id(data["platform"]), chat_id=str(data["chat_id"]), name=data.get("name", "Home"), ) diff --git a/gateway/session.py b/gateway/session.py index c14e9bd0..af00170f 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -123,8 +123,9 @@ class SessionSource: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": + from hermes_cli.plugins import resolve_platform_id return cls( - platform=Platform(data["platform"]), + platform=resolve_platform_id(data["platform"]), chat_id=str(data["chat_id"]), chat_name=data.get("chat_name"), chat_type=data.get("chat_type", "dm"), @@ -409,10 +410,8 @@ class SessionEntry: platform = None if data.get("platform"): - try: - platform = Platform(data["platform"]) - except ValueError as e: - logger.debug("Unknown platform value %r: %s", data["platform"], e) + from hermes_cli.plugins import resolve_platform_id + platform = resolve_platform_id(data["platform"]) return cls( session_key=data["session_key"], diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 99b708f9..4aa41b55 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -883,6 +883,32 @@ class PluginPlatformIdentifier: return f"PluginPlatformIdentifier({self.value!r})" +def resolve_platform_id(value: str): + """Resolve a platform-name string to a ``Platform`` enum member or + a ``PluginPlatformIdentifier``. + + Used by ``from_dict`` deserializers for ``SessionSource``, + ``SessionEntry``, ``HomeChannel`` etc. Without this fallback, + daemon restart would lose every session whose platform was + contributed by a plugin (e.g., ``molecule-a2a``) because the + bare ``Platform(value)`` call raises ``ValueError`` for unknown + enum members. + + The fallback is intentionally narrow: an unknown name is only + accepted as a plugin identifier if a currently-loaded plugin has + actually claimed that name via ``register_platform_adapter``. Bare + typos and corrupted state still raise ``ValueError`` as before, so + silent state-corruption doesn't slip past restore. + """ + from gateway.config import Platform # late import to avoid cycle + try: + return Platform(value) + except ValueError: + if get_plugin_platform_adapter(value) is not None: + return PluginPlatformIdentifier(value) + raise + + def get_plugin_commands() -> Dict[str, dict]: """Return the full plugin commands dict (name → {handler, description, plugin}).