Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5741,6 +5741,18 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
)


# Per-process memo for resolve_nous_access_token. Startup runs
# check_tool_availability once per managed-tool check_fn (browser, image_gen,
# etc.), and each one independently triggers a ~15s blocking token-refresh
# network call when the stored token is expired. On a slow/constrained host that
# serial burst stretches startup to many minutes. A short-TTL memo collapses the
# burst into a single network round-trip; callers that need freshness use
# separate flows (force_fresh / refresh_nous_oauth_pure) and are unaffected.
_RESOLVE_TOKEN_CACHE_LOCK = threading.Lock()
_RESOLVE_TOKEN_CACHE: "tuple[float, str] | None" = None
_RESOLVE_TOKEN_CACHE_TTL_S = 5.0


def resolve_nous_access_token(
*,
timeout_seconds: float = 15.0,
Expand All @@ -5749,6 +5761,16 @@ def resolve_nous_access_token(
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
# Memo: collapse the startup burst of managed-tool check_fns into one
# network refresh. Only cache a successful, non-forced resolution for a
# short window; force_fresh / error paths bypass and don't populate it.
if not insecure and ca_bundle is None:
with _RESOLVE_TOKEN_CACHE_LOCK:
if _RESOLVE_TOKEN_CACHE is not None:
cached_at, cached_token = _RESOLVE_TOKEN_CACHE
if (time.monotonic() - cached_at) < _RESOLVE_TOKEN_CACHE_TTL_S:
return cached_token
with _provider_state_transaction("nous") as (
auth_store,
state,
Expand Down Expand Up @@ -5803,6 +5825,15 @@ def resolve_nous_access_token(
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
if merged_shared:
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
# Populate the memo on the valid-token fast path too: the
# startup burst usually finds a *valid* token, but each
# check_fn call still pays two cross-process file locks and
# state reads to reach this return. The token has at least
# refresh_skew_seconds (>= 120s) of life here, so a 5s memo
# can never serve an expired token.
if not insecure and ca_bundle is None:
with _RESOLVE_TOKEN_CACHE_LOCK:
_RESOLVE_TOKEN_CACHE = (time.monotonic(), access_token)
return access_token

if not isinstance(refresh_token, str) or not refresh_token:
Expand Down Expand Up @@ -5860,7 +5891,11 @@ def resolve_nous_access_token(
}
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
_write_shared_nous_state(state)
return state["access_token"]
resolved = state["access_token"]
if not insecure and ca_bundle is None:
with _RESOLVE_TOKEN_CACHE_LOCK:
_RESOLVE_TOKEN_CACHE = (time.monotonic(), resolved)
return resolved


def refresh_nous_oauth_pure(
Expand Down
5 changes: 5 additions & 0 deletions tests/hermes_cli/test_nous_portal_staging_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def _write_auth_file(self, tmp_path, *, stored_portal_url):
def _run_and_capture(self, monkeypatch, auth):
seen_portal_urls = []

# 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)

def _fake_refresh(*, client, portal_base_url, client_id, refresh_token):
seen_portal_urls.append(portal_base_url)
return {
Expand Down
96 changes: 96 additions & 0 deletions tests/hermes_cli/test_resolve_token_memo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for the resolve_nous_access_token startup-burst memo (PR #66016).

The memo collapses the startup burst of managed-tool check_fn calls into a
single expensive resolution: within the short TTL, repeat calls return the
cached token without re-entering _provider_state_transaction (two
cross-process file locks + state reads) or triggering a network refresh.
"""

import json
import time

import pytest

import hermes_cli.auth as auth


@pytest.fixture(autouse=True)
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)
yield


def _write_valid_auth_file(tmp_path, token="memo-token"):
(tmp_path / "auth.json").write_text(
json.dumps(
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"access_token": token,
"refresh_token": "r",
"client_id": "hermes-cli-vps",
"expires_at": time.strftime(
"%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(time.time() + 3600)
),
}
},
}
)
)


def _count_transactions(monkeypatch):
calls = {"n": 0}
real = auth._provider_state_transaction

def _counting(provider):
calls["n"] += 1
return real(provider)

monkeypatch.setattr(auth, "_provider_state_transaction", _counting)
return calls


def test_repeat_calls_within_ttl_hit_memo(monkeypatch, tmp_path):
_write_valid_auth_file(tmp_path)
calls = _count_transactions(monkeypatch)

first = auth.resolve_nous_access_token()
second = auth.resolve_nous_access_token()
third = auth.resolve_nous_access_token()

assert first == second == third == "memo-token"
assert calls["n"] == 1, (
"repeat calls within the TTL must not re-enter the state transaction"
)


def test_memo_expires_after_ttl(monkeypatch, tmp_path):
_write_valid_auth_file(tmp_path)
calls = _count_transactions(monkeypatch)

auth.resolve_nous_access_token()
cached_at, tok = auth._RESOLVE_TOKEN_CACHE
monkeypatch.setattr(
auth,
"_RESOLVE_TOKEN_CACHE",
(cached_at - auth._RESOLVE_TOKEN_CACHE_TTL_S - 1.0, tok),
)
auth.resolve_nous_access_token()

assert calls["n"] == 2, "an expired memo must re-resolve"


def test_insecure_callers_bypass_memo(monkeypatch, tmp_path):
_write_valid_auth_file(tmp_path)
calls = _count_transactions(monkeypatch)

auth.resolve_nous_access_token()
auth.resolve_nous_access_token(insecure=True)

assert calls["n"] == 2, "insecure callers must bypass the memo entirely"
Loading