fix(auth): scope the resolve_nous_access_token memo to the active profile

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)
This commit is contained in:
pierrenode
2026-08-03 20:00:29 +03:00
committed by Teknium
parent 3b044261b6
commit 173105ce6f
3 changed files with 50 additions and 10 deletions
+8 -6
View File
@@ -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):
@@ -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)
+41 -3
View File
@@ -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"
)