Files
hermes-agent/tests/agent/test_multiplex_cloud_credential_clients.py
Teknium 2cc6d88c1d fix(multiplex): build Bedrock and Entra credential clients from the routed profile
boto3 and azure-identity freeze the credential chain into the client at
construction, and the process env under a multiplexed turn belongs to the launch
profile. A region-only (bedrock) / config-only (lru_cache) slot therefore signed a
served profile's calls with the launch profile's keys and served its account's
model list to everyone.

Under a HERMES_HOME override the clients are built from the profile's secret
scope (AWS_* / AZURE_* from its .env) and cached per (home, service, region) /
(home, config); the discovery cache key carries the home. The unscoped path keeps
the region slot and the maxsize=1 lru byte-for-byte.
2026-09-12 01:35:05 -07:00

91 lines
3.7 KiB
Python

"""Cloud-SDK credential clients under ``gateway.multiplex_profiles``: boto3 and azure-identity freeze the
credential chain into the client at construction, so a slot keyed by region / config alone would sign a
served profile's calls with the launch profile's keys. Each test warms the client under profile A, reads
under routed profile B whose ``.env`` differs (real temp homes, real secret scope; no mocks of the cache).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@pytest.fixture
def two_profiles(tmp_path, monkeypatch):
a = tmp_path / ".hermes"
b = a / "profiles" / "b"
b.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(a))
for var in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE",
"AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"):
monkeypatch.delenv(var, raising=False)
for name, home in (("A", a), ("B", b)):
(home / ".env").write_text(
f"AWS_ACCESS_KEY_ID=AKIA{name * 16}\nAWS_SECRET_ACCESS_KEY=secret-{name}\n"
f"AZURE_TENANT_ID=tenant-{name}\nAZURE_CLIENT_ID=client-{name}\nAZURE_CLIENT_SECRET=s-{name}\n",
encoding="utf-8")
return a, b
def _under(home: Path, fn):
home_token = set_hermes_home_override(str(home))
scope_token = set_secret_scope(build_profile_secret_scope(home))
try:
return fn()
finally:
reset_secret_scope(scope_token)
reset_hermes_home_override(home_token)
def test_bedrock_clients_sign_with_the_routed_profiles_aws_keys(two_profiles):
pytest.importorskip("boto3")
from agent import bedrock_adapter as ba
a, b = two_profiles
ba.reset_client_cache()
try:
client_a = _under(a, lambda: ba._get_bedrock_runtime_client("us-east-1"))
client_b = _under(b, lambda: ba._get_bedrock_runtime_client("us-east-1"))
assert client_b is not client_a
keys = {c._request_signer._credentials.get_frozen_credentials().access_key for c in (client_a, client_b)}
assert keys == {"AKIA" + "A" * 16, "AKIA" + "B" * 16}
# Per-profile slots stay hot; eviction under B leaves A's client alone.
assert _under(a, lambda: ba._get_bedrock_runtime_client("us-east-1")) is client_a
assert _under(b, lambda: ba.invalidate_runtime_client("us-east-1")) is True
assert _under(a, lambda: ba._get_bedrock_runtime_client("us-east-1")) is client_a
finally:
ba.reset_client_cache()
def test_azure_entra_credential_is_built_from_the_routed_profiles_scope(two_profiles):
from agent import azure_identity_adapter as az
a, b = two_profiles
seen: list[tuple] = []
class _FakeSDK:
def ClientSecretCredential(self, tenant, client, secret):
seen.append((tenant, client))
return object()
def DefaultAzureCredential(self, **kwargs):
seen.append(("default-chain",))
return object()
az.reset_credential_cache()
try:
cfg = az.EntraIdentityConfig()
import unittest.mock as mock
with mock.patch.object(az, "_require_azure_identity", lambda: _FakeSDK()):
cred_a = _under(a, lambda: az.build_credential(cfg))
cred_b = _under(b, lambda: az.build_credential(cfg))
assert cred_b is not cred_a
assert seen == [("tenant-A", "client-A"), ("tenant-B", "client-B")]
assert _under(a, lambda: az.build_credential(cfg)) is cred_a # cached per profile, not rebuilt
finally:
az.reset_credential_cache()