From 173105ce6f41dd37ce4d7ef149291f0d99a5c963 Mon Sep 17 00:00:00 2001 From: pierrenode <298902573+pierrenode@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:00:29 +0300 Subject: [PATCH] fix(auth): scope the resolve_nous_access_token memo to the active profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_nous_access_token()'s 5s startup-burst memo (#76930) cached the resolved Nous Portal access token in a single module-level slot keyed by nothing but wall-clock time. The underlying resolution is profile-scoped: _auth_file_path() reads get_hermes_home(), which checks the context-local _HERMES_HOME_OVERRIDE ContextVar before falling back to the HERMES_HOME env var — gateway/run.py and tui_gateway/server.py set that override per-profile for multiplex concurrency. In a multiplex gateway serving two profiles with different authenticated Nous accounts, if profile A's context resolves a token and profile B's context calls resolve_nous_access_token() within the next 5 seconds, profile B received profile A's cached token — used to authenticate against the managed tool gateway / relay self-provisioning under the wrong account. Key the memo by str(get_hermes_home()) instead of a single slot, so each profile's context reads only its own cached token. The lock around read/write is unchanged; only the cache's shape moved from a single (timestamp, token) tuple to a dict keyed by resolved home. (cherry picked from commit 9d8846b88cfd2ea74c6958d5f8f28a50880dda75) --- hermes_cli/auth.py | 14 +++--- .../test_nous_portal_staging_allowlist.py | 2 +- tests/hermes_cli/test_resolve_token_memo.py | 44 +++++++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 4424058574..8bbcc5b795 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -32,7 +32,7 @@ from urllib.parse import urlparse from hermes_cli.config import ( get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write) -from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir +from hermes_constants import OPENROUTER_BASE_URL, hermes_home_key, secure_parent_dir from agent.credential_persistence import sanitize_borrowed_credential_payload from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value # noqa: F401 (env_float: agent.credential_pool reads auth_mod.env_float) from hermes_cli.auth_zai_kimi import ( # noqa: F401 re-exported @@ -1598,8 +1598,11 @@ _NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ # Per-process memo for resolve_nous_access_token: startup runs one check_fn per managed tool and # each would trigger its own ~15s blocking refresh of an expired token; a short-TTL memo collapses # the burst into one round-trip. Callers needing freshness use force_fresh/refresh_nous_oauth_pure. +# Keyed by hermes_home_key(): the resolution itself is profile-scoped (_auth_file_path reads the +# per-turn HERMES_HOME override a multiplex gateway sets), so a single slot would hand profile A's +# Portal bearer to profile B for up to the TTL. _RESOLVE_TOKEN_CACHE_LOCK = threading.Lock() -_RESOLVE_TOKEN_CACHE: "tuple[float, str] | None" = None +_RESOLVE_TOKEN_CACHE: "dict[str, tuple[float, str]]" = {} _RESOLVE_TOKEN_CACHE_TTL_S = 5.0 @@ -1628,20 +1631,19 @@ def resolve_nous_access_token( ca_bundle: Optional[str] = None, refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> str: """Resolve a refresh-aware Nous Portal access token for managed tool gateways.""" - global _RESOLVE_TOKEN_CACHE # Only a default-TLS resolution is memoised; error paths never populate the memo. memoable = not insecure and ca_bundle is None + cache_key = hermes_home_key() if memoable: with _RESOLVE_TOKEN_CACHE_LOCK: - cached = _RESOLVE_TOKEN_CACHE + cached = _RESOLVE_TOKEN_CACHE.get(cache_key) if cached is not None and (time.monotonic() - cached[0]) < _RESOLVE_TOKEN_CACHE_TTL_S: return cached[1] def _memo(token: str) -> str: - global _RESOLVE_TOKEN_CACHE if memoable: with _RESOLVE_TOKEN_CACHE_LOCK: - _RESOLVE_TOKEN_CACHE = (time.monotonic(), token) + _RESOLVE_TOKEN_CACHE[cache_key] = (time.monotonic(), token) return token with _provider_state_transaction("nous") as (auth_store, state, state_source_path): diff --git a/tests/hermes_cli/test_nous_portal_staging_allowlist.py b/tests/hermes_cli/test_nous_portal_staging_allowlist.py index d3fc7781b7..5462ed6176 100644 --- a/tests/hermes_cli/test_nous_portal_staging_allowlist.py +++ b/tests/hermes_cli/test_nous_portal_staging_allowlist.py @@ -91,7 +91,7 @@ class TestResolveAccessTokenEnvOverrideWins: # The resolve memo is module-level state; clear it so each test's # resolution actually exercises the refresh path instead of serving # a token cached by a previous test. - monkeypatch.setattr(auth, "_RESOLVE_TOKEN_CACHE", None) + monkeypatch.setattr(auth, "_RESOLVE_TOKEN_CACHE", {}) def _fake_refresh(*, client, portal_base_url, client_id, refresh_token): seen_portal_urls.append(portal_base_url) diff --git a/tests/hermes_cli/test_resolve_token_memo.py b/tests/hermes_cli/test_resolve_token_memo.py index 8fabb8d64c..58ed93af24 100644 --- a/tests/hermes_cli/test_resolve_token_memo.py +++ b/tests/hermes_cli/test_resolve_token_memo.py @@ -19,7 +19,7 @@ def _fresh_memo(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False) monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False) - monkeypatch.setattr(auth, "_RESOLVE_TOKEN_CACHE", None) + monkeypatch.setattr(auth, "_RESOLVE_TOKEN_CACHE", {}) yield @@ -75,11 +75,12 @@ def test_memo_expires_after_ttl(monkeypatch, tmp_path): calls = _count_transactions(monkeypatch) auth.resolve_nous_access_token() - cached_at, tok = auth._RESOLVE_TOKEN_CACHE + cache_key = auth.hermes_home_key() + cached_at, tok = auth._RESOLVE_TOKEN_CACHE[cache_key] monkeypatch.setattr( auth, "_RESOLVE_TOKEN_CACHE", - (cached_at - auth._RESOLVE_TOKEN_CACHE_TTL_S - 1.0, tok), + {cache_key: (cached_at - auth._RESOLVE_TOKEN_CACHE_TTL_S - 1.0, tok)}, ) auth.resolve_nous_access_token() @@ -94,3 +95,40 @@ def test_insecure_callers_bypass_memo(monkeypatch, tmp_path): auth.resolve_nous_access_token(insecure=True) assert calls["n"] == 2, "insecure callers must bypass the memo entirely" + + +def test_memo_does_not_leak_across_multiplex_profile_contexts(tmp_path): + """A multiplex gateway scopes each profile's context via + hermes_constants.set_hermes_home_override (gateway/run.py, + tui_gateway/server.py), not the HERMES_HOME env var — the memo must key + on that same resolved home, or one profile's context can read another + profile's already-cached Nous access token for up to the TTL window. + """ + import hermes_constants + + profile_a = tmp_path / "profile-a" + profile_b = tmp_path / "profile-b" + profile_a.mkdir() + profile_b.mkdir() + _write_valid_auth_file(profile_a, token="token-a") + _write_valid_auth_file(profile_b, token="token-b") + + token_a = token_b = None + reset_token = hermes_constants.set_hermes_home_override(str(profile_a)) + try: + token_a = auth.resolve_nous_access_token() + finally: + hermes_constants.reset_hermes_home_override(reset_token) + + # Still well within the 5s TTL — this is exactly the race window: profile + # B's context calls resolve_nous_access_token() shortly after profile A's. + reset_token = hermes_constants.set_hermes_home_override(str(profile_b)) + try: + token_b = auth.resolve_nous_access_token() + finally: + hermes_constants.reset_hermes_home_override(reset_token) + + assert token_a == "token-a" + assert token_b == "token-b", ( + "profile B's context must not receive profile A's cached access token" + )