Browse Source

Scope the tool config cache by serving profile, not by process

_resolved_chatto_config was one module-level slot, but Hermes multiplexes
several profiles through a single gateway process and runs hermes_validate_config
once per profile (run_adapters.py calls _create_adapter for each). The last
profile validated therefore owned the snapshot, and a routed cron fire or turn
for another profile sent with the wrong profile's bot token and base_url.

Snapshots now live in _resolved_chatto_configs keyed by _profile_scope_key() —
hermes_home_key(), the key the platform registry scopes its own entries by — and
the fallback tier caches into the same map under the same key.

The fallback's secrets prefer the scope Hermes already installed for the served
profile (current_secret_scope) over rebuilding one from get_hermes_home(): under
multiplexing the launch home is not the profile being served, so rebuilding it
answers with another profile's credentials.

Verified by mutation, not just by green: collapsing the map back to one slot
fails the profile test with token-b where token-a is expected, and forcing the
fallback to ignore the installed scope fails it with launch-home-token.
Paul Klumpp 2 weeks ago
parent
commit
7daae92afe
4 changed files with 151 additions and 27 deletions
  1. 35 0
      AGENTS.md
  2. 57 24
      adapter.py
  3. 1 1
      skills/chatto-platform-plugin/SKILL.md
  4. 58 2
      test_adapter.py

+ 35 - 0
AGENTS.md

@@ -446,6 +446,41 @@ configuration docs must be able to predict what happens when they set nothing.
 Secrets (`CHATTO_TOKEN`, `CHATTO_PASSWORD`) belong in `~/.hermes/.env`, never
 in `config.yaml` — docs and setup wizard rely on that split.
 
+A raw value from **anywhere** goes through `ConfigField.coerce`, never in
+as-is. `.env` lines are strings whatever the field means: a list field holding
+`"a,b"` turns `room_id in require_mention + optional_mention` into a substring
+test, and a bool field holding `"false"` is true. The parse lives once per kind
+in the `_parse_*` helpers; the `_get_env_or_extra_*` wrappers only decide where
+a value comes from. `resolve()` and `overlay_profile_secrets()` must both go
+through `coerce` — the overlay is the path a cron or CLI session takes, and it
+is the one no env layer has already normalized.
+
+### One config per serving profile, never one per process
+
+**Hermes multiplexes several profiles through one gateway process, so any
+process-wide config cache is a cross-tenant credential leak waiting to happen.**
+
+`hermes_validate_config` runs once per profile (`run_adapters.py` calls
+`_create_adapter` for each), and each call resolves a different `PlatformConfig`.
+`adapter.py` therefore keeps them in `_resolved_chatto_configs`, a dict keyed by
+`_profile_scope_key()` — `hermes_constants.hermes_home_key()`, the same key the
+platform registry scopes its own entries by, so both agree on what "the serving
+profile" means. The fallback tier in `_chatto_config_for_tools` caches into the
+same map under the same key.
+
+For the secrets the fallback layers on top, prefer the scope Hermes already
+installed (`agent.secret_scope.current_secret_scope()`): that mapping is the
+served profile's authoritative secret set. Only when none is installed (CLI, the
+standalone sender) does `build_profile_secret_scope(get_hermes_home())` apply —
+rebuilding one from the *launch* home while another profile is being served
+answers with the wrong credentials.
+
+The same reasoning rules out `os.environ` as a source of truth here: under
+multiplexing it holds the launch profile's values, first-writer-wins
+(`gateway/platforms/_shared.get_scoped_secret` is the upstream reader that
+handles this). See *Auth defaults to closed* for what fail-closed means for
+these values.
+
 ## Typed access
 
 **Work against the declared types. Do not reach for values dynamically when a

+ 57 - 24
adapter.py

@@ -3277,7 +3277,30 @@ async def hermes_standalone_sender_fn(
 # Resolved Chatto configuration — populated by ``hermes_validate_config`` so
 # plugin tools can access it without re-reading env vars or duplicating
 # ``ChattoConfiguration`` logic.
-_resolved_chatto_config: ChattoConfiguration | None = None
+#
+# Keyed by the profile the snapshot was validated for, not stored in one slot:
+# Hermes multiplexes several profiles through a single gateway process and runs
+# every profile's own validate_config (run_adapters.py, one _create_adapter per
+# profile), so a single global would be whichever profile validated last — and a
+# routed cron fire or turn for another profile would then send with the wrong
+# profile's bot token. The key is the same home key the platform registry scopes
+# its own entries by, so both agree on what "the serving profile" means.
+_resolved_chatto_configs: dict[str, ChattoConfiguration] = {}
+
+
+def _profile_scope_key() -> str:
+    """Registry key of the profile being served right now.
+
+    Falls back to a constant when ``hermes_constants`` is unavailable (a bare
+    standalone sender process): one profile there, so one key loses nothing.
+    """
+    try:
+        from hermes_constants import hermes_home_key
+
+        return hermes_home_key()
+    except Exception:
+        logger.debug("Chatto: no profile scope key available", exc_info=True)
+        return ""
 
 
 # How similar an ``extra`` key must be to a declared config_key before the
@@ -3349,8 +3372,7 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
     # Publish for the plugin tools only now: a config we just rejected is not
     # one they should send with, and a rejected call must not overwrite a
     # snapshot an earlier, accepted call left behind.
-    global _resolved_chatto_config
-    _resolved_chatto_config = resolved
+    _resolved_chatto_configs[_profile_scope_key()] = resolved
     return True
 
 
@@ -3662,19 +3684,24 @@ def _chatto_config_for_tools() -> ChattoConfiguration | None:
     """Resolve the Chatto configuration for plugin tool handlers.
 
     Two tiers, most specific first:
-    1. ``_resolved_chatto_config`` — set by ``hermes_validate_config`` at gateway
-       load; the authoritative snapshot in a running gateway.
-    2. A fresh read of config.yaml plus the profile's own .env, for the cron and
-       standalone sessions where the gateway load hooks never ran and the dotenv
-       residue has been stripped from ``os.environ``. Mirrors how the built-in
-       send_message tool resolves its pconfig (tools/send_message_tool.py).
+    1. The snapshot ``hermes_validate_config`` published for *this* profile — the
+       authoritative one in a running gateway.
+    2. A fresh read of config.yaml plus the serving profile's own secrets, for
+       the cron and standalone sessions where the gateway load hooks never ran
+       and the dotenv residue has been stripped from ``os.environ``. Mirrors how
+       the built-in send_message tool resolves its pconfig
+       (tools/send_message_tool.py).
+
+    Both tiers are keyed by the serving profile, so a multiplexed gateway hands
+    each profile its own credentials rather than whichever one was served last.
 
     Returns ``None`` when neither tier yields a configuration; the caller
     reports that rather than guessing at credentials.
     """
-    global _resolved_chatto_config
-    if _resolved_chatto_config is not None:
-        return _resolved_chatto_config
+    scope_key = _profile_scope_key()
+    snapshot = _resolved_chatto_configs.get(scope_key)
+    if snapshot is not None:
+        return snapshot
 
     # config.yaml's own ``extra`` block, so a deployment configured there — and
     # nowhere in .env — is honoured outside the gateway process too.
@@ -3685,29 +3712,35 @@ def _chatto_config_for_tools() -> ChattoConfiguration | None:
         pconfig = load_gateway_config().platforms.get(
             GatewayPlatform(ChattoConstants.PLATFORM_NAME)
         )
-        _resolved_chatto_config = ChattoConfiguration(
-            pconfig=_pconfig_or_empty(pconfig)
-        )
+        resolved = ChattoConfiguration(pconfig=_pconfig_or_empty(pconfig))
     except Exception as exc:
         logger.warning("Chatto tools: could not read gateway config: %s", exc)
         return None
 
     # In a fresh cron scope the dotenv residue is stripped from os.environ, so
-    # ChattoConfiguration's env layer sees nothing. build_profile_secret_scope
-    # re-reads the profile's own .env directly — it works in gateway, cron and
-    # CLI contexts alike — so every field that is not an explicit user value
-    # gets a second chance from there.
+    # ChattoConfiguration's env layer sees nothing. The secrets then come from
+    # the scope Hermes installed for the profile being served when there is one —
+    # that mapping is that profile's authoritative secret set, and rebuilding
+    # one from the launch home would answer with another profile's credentials.
+    # With no scope installed (CLI, bare standalone sender) the profile's own
+    # .env is the right source, and build_profile_secret_scope reads it
+    # directly in gateway, cron and CLI contexts alike.
     try:
-        from agent.secret_scope import build_profile_secret_scope
-        from hermes_constants import get_hermes_home
+        from agent.secret_scope import current_secret_scope
+
+        profile_secrets = current_secret_scope()
+        if profile_secrets is None:
+            from agent.secret_scope import build_profile_secret_scope
+            from hermes_constants import get_hermes_home
 
-        profile_secrets = dict(build_profile_secret_scope(get_hermes_home()))
+            profile_secrets = dict(build_profile_secret_scope(get_hermes_home()))
     except Exception as exc:
         logger.warning("Chatto tools: profile secret scope build failed: %s", exc)
         profile_secrets = {}
 
-    _resolved_chatto_config.overlay_profile_secrets(profile_secrets)
-    return _resolved_chatto_config
+    resolved.overlay_profile_secrets(profile_secrets)
+    _resolved_chatto_configs[scope_key] = resolved
+    return resolved
 
 
 def _pconfig_or_empty(pconfig: PlatformConfig | None) -> PlatformConfig:

+ 1 - 1
skills/chatto-platform-plugin/SKILL.md

@@ -51,7 +51,7 @@ Looks up the user by login or ID and posts into the DM room.
 - **Async handlers need `is_async=True`** — the default is sync; an `async def` handler registered without it returns an unawaited coroutine and the dispatcher fails with `tool_result_contract: unsupported result type: coroutine` (`tools/registry.py: dispatch()`).
 - **Handlers must return JSON strings** (`json.dumps({...})`), never plain dicts — `_normalize_handler_result` accepts only str or the multimodal envelope. Annotate them `-> str` too: the annotation is what makes a regression to `dict` a type error instead of a runtime break.
 - **Early exits raise `_ToolRefusal`, they do not return.** The `async with _tool_client(cfg)` context manager and the `_refuse(...)` helper keep one result shape per failure; the `_handle_*` wrapper turns a refusal back into its JSON and catches `ChattoError`/`ChattoAuthError` around it. Never `except Exception: pass` — the AGENTS.md rule about naming each recovery applies here too.
-- **Config access: never read `os.environ` in handlers.** `.env` credentials like `CHATTO_TOKEN` are profile-scoped and NOT visible in cron fires' os.environ, and `hermes_validate_config` never runs in a fresh cron session. Resolve config through `_chatto_config_for_tools()` in adapter.py: (1) module-level `_resolved_chatto_config`, installed by `hermes_validate_config` only after it accepted the config; (2) a fresh `load_gateway_config()` read for the `chatto-platform` platform entry, so `config.yaml`'s own `extra` block is honoured, re-filled from `build_profile_secret_scope(get_hermes_home())` (`agent.secret_scope`) — re-reads the profile's own `.env`, works in gateway, cron and CLI alike; (3) clean error.
+- **Config access: never read `os.environ` in handlers.** `.env` credentials like `CHATTO_TOKEN` are profile-scoped and NOT visible in cron fires' os.environ, and `hermes_validate_config` never runs in a fresh cron session. Resolve config through `_chatto_config_for_tools()` in adapter.py: (1) `_resolved_chatto_configs[_profile_scope_key()]` — the snapshot `hermes_validate_config` installed for *this* profile, only after it accepted the config; the map is keyed by `hermes_home_key()` because Hermes multiplexes profiles in one process, so a single module-level slot would hand the tools whichever profile validated last; (2) a fresh `load_gateway_config()` read for the `chatto-platform` platform entry, so `config.yaml`'s own `extra` block is honoured, re-filled from the installed secret scope (`agent.secret_scope.current_secret_scope()`) or, when none is installed, from `build_profile_secret_scope(get_hermes_home())`, which re-reads the profile's own `.env` and works in gateway, cron and CLI alike; (3) clean error.
 - **Never put a credential or a server URL in a tool schema.** The configured bot token must not be reachable from an argument: a `base_url` parameter would send it as `Authorization: Bearer …` to whatever host the caller names, and any message the bot reads is something the model could be talked into repeating. Take the client from `async with _tool_client(cfg) as client:` — it is the only sanctioned way to build one.
 - **A half-delivered send says so.** `_post_chunks` reports `partial: true` plus the `message_ids` that went out, so a retry repeats only what is missing. Keep that contract when adding a send path.
 - **Verify in BOTH contexts:** a gateway-session `tool_call` is not enough — also fire a `cronjob_manage action='run'` test when the tool will run from cron, because cron sessions exercise the config-fallback tier gateway sessions never hit. Debug order: `tool_result_contract` error → contract bug (async/return type); `AttributeError` on the client → wrong stub/service name; `permission_denied` → server-side limit.

+ 58 - 2
test_adapter.py

@@ -3905,7 +3905,7 @@ class TestPluginToolHandlers:
             platforms={Platform(ChattoConstants.PLATFORM_NAME): yaml_cfg}
         )
         with (
-            patch("adapter._resolved_chatto_config", None),
+            patch("adapter._resolved_chatto_configs", {}),
             patch.object(gw_config, "load_gateway_config", return_value=gateway_cfg),
             # None in sys.modules makes the import fail, as in a bare cron process.
             patch.dict(sys.modules, {"agent.secret_scope": None}),
@@ -3915,6 +3915,62 @@ class TestPluginToolHandlers:
         assert resolved.token.value == "yaml-token"
         assert resolved.base_url.value == "https://yaml.example"
 
+    async def test_tool_config_is_scoped_to_the_serving_profile(
+        self, tmp_path, monkeypatch
+    ):
+        """Hermes multiplexes several profiles through one process and runs
+        hermes_validate_config once per profile. A single process-wide snapshot
+        means the last profile validated wins, so a routed cron fire or gateway
+        turn for profile B would send with profile A's bot token — the two
+        profiles here are two Hermes homes, exactly what multiplexing serves.
+        """
+        _clear_chatto_env(monkeypatch)
+        profile_a = tmp_path / "a"
+        profile_b = tmp_path / "b"
+        profile_a.mkdir()
+        profile_b.mkdir()
+
+        with patch("adapter._resolved_chatto_configs", {}):
+            monkeypatch.setenv("HERMES_HOME", str(profile_a))
+            monkeypatch.setenv("CHATTO_TOKEN", "token-a")
+            assert validate_config(_make_config()) is True
+            monkeypatch.setenv("HERMES_HOME", str(profile_b))
+            monkeypatch.setenv("CHATTO_TOKEN", "token-b")
+            assert validate_config(_make_config()) is True
+
+            monkeypatch.setenv("HERMES_HOME", str(profile_a))
+            assert _chatto_config_for_tools().token.value == "token-a"
+            monkeypatch.setenv("HERMES_HOME", str(profile_b))
+            assert _chatto_config_for_tools().token.value == "token-b"
+
+    async def test_tool_fallback_prefers_the_installed_profile_scope(self):
+        """The secret scope Hermes installs for a routed profile is that
+        profile's authoritative secret mapping. Rebuilding one from the launch
+        home's .env instead hands the tools another profile's credentials.
+        """
+        import gateway.config as gw_config
+
+        _ensure_chatto_registered()
+        yaml_cfg = gw_config.PlatformConfig(enabled=True, extra={})
+        gateway_cfg = gw_config.GatewayConfig(
+            platforms={Platform(ChattoConstants.PLATFORM_NAME): yaml_cfg}
+        )
+        with (
+            patch("adapter._resolved_chatto_configs", {}),
+            patch.object(gw_config, "load_gateway_config", return_value=gateway_cfg),
+            patch(
+                "agent.secret_scope.current_secret_scope",
+                return_value={"CHATTO_TOKEN": "scoped-token"},
+            ),
+            patch(
+                "agent.secret_scope.build_profile_secret_scope",
+                return_value={"CHATTO_TOKEN": "launch-home-token"},
+            ),
+        ):
+            resolved = _chatto_config_for_tools()
+        assert resolved is not None
+        assert resolved.token.value == "scoped-token"
+
     async def test_send_room_works_with_mention_lists_from_the_profile_env(
         self, monkeypatch
     ):
@@ -3938,7 +3994,7 @@ class TestPluginToolHandlers:
         client.get_room = AsyncMock(return_value=_make_room_state(room))
 
         with (
-            patch("adapter._resolved_chatto_config", None),
+            patch("adapter._resolved_chatto_configs", {}),
             patch.object(gw_config, "load_gateway_config", return_value=gateway_cfg),
             patch(
                 "agent.secret_scope.build_profile_secret_scope",