Files
hermes-agent/tests/run_agent/test_credential_rotation_route_settings.py
T
Jeongseok Kang 2d70f56327 fix(agent): adopt .env credential/base-url edits at the turn boundary (#67843)
* fix(agent): adopt .env credential/base-url edits at the turn boundary

A Settings save (desktop PUT /api/env, hermes setup) updates .env and
the saving process's os.environ, but a live session worker keeps the
base_url/api_key captured at agent init until restart — an open chat
silently kept calling the old endpoint (e.g. a local-server key sent to
api.openai.com, failing with an opaque 401).

Add AIAgent._try_refresh_env_client_credentials(), called at the start
of each conversation turn: re-resolve the provider's env-sourced
credentials (load_env() is mtime-memoized, so an unchanged file costs
one stat()) and rebuild the client via the existing
_replace_primary_openai_client machinery when the user edited them.

The refresh reacts only to env edits — resolved values changed since
the last look — never to mere divergence from the agent's current
values: credential-pool rotation and failover legitimately move the
session off the env credential, and stomping those back would flap.
Config model.base_url / pool custom endpoints keep precedence: edits
are only adopted while the session still runs on the registry default
or the previously-seen env value.

Lift _get_env_prefer_dotenv out of _seed_from_env to module level
(get_env_prefer_dotenv) so both the pool seeder and the per-turn
refresh share the same .env-over-os.environ resolution, including the
op:// indirection handling.

Fixes #67821

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): address sweeper review on env credential refresh

- Cover named custom providers (#67935): provider="custom" has no
  PROVIDER_REGISTRY entry, so resolve the config block's key_env through
  the same lookup the runtime resolver uses.
- Make the edit baseline transactional: a failed client rebuild rolls the
  agent back and leaves _env_creds_seen un-advanced so the unchanged edit
  is retried next turn.
- Recompute route-derived TLS material and default headers on a base-url
  change, via a _reapply_route_client_config helper shared with
  credential-pool rotation so the two paths cannot drift.
- Rebase onto main: get_env_prefer_dotenv keeps the scoped _get_secret
  semantics from the profile-isolation fix (no raw os.environ reads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: map jskang@lablup.com to rapsealk

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-08-04 17:53:17 +00:00

112 lines
3.3 KiB
Python

"""Credential rotation must not carry route-scoped TLS policy."""
from types import MethodType, SimpleNamespace
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
def test_credential_rotation_replaces_route_scoped_tls_settings():
agent = SimpleNamespace(
api_mode="chat_completions",
provider="custom",
model="shared-model",
api_key="old",
base_url="https://a.example/v1",
_client_kwargs={
"api_key": "old",
"base_url": "https://a.example/v1",
"ssl_verify": False,
"ssl_ca_cert": "/a.pem",
},
_apply_client_headers_for_base_url=MagicMock(),
_replace_primary_openai_client=MagicMock(),
)
agent._reapply_route_client_config = MethodType(
AIAgent._reapply_route_client_config,
agent,
)
entry = SimpleNamespace(
runtime_api_key="new",
access_token="",
runtime_base_url="https://b.example/v1",
base_url="https://b.example/v1",
)
config = {
"custom_providers": [
{
"name": "b",
"base_url": "https://b.example/v1",
"ssl_verify": True,
}
]
}
with patch("hermes_cli.config.load_config_readonly", return_value=config):
AIAgent._swap_credential(agent, entry)
assert agent._client_kwargs["ssl_verify"] is True
assert "ssl_ca_cert" not in agent._client_kwargs
agent._replace_primary_openai_client.assert_called_once_with(
reason="credential_rotation"
)
def test_credential_rotation_does_not_carry_global_headers_across_routes():
agent = SimpleNamespace(
api_mode="chat_completions",
provider="custom",
model="shared-model",
api_key="old",
base_url="https://a.example/v1",
_client_kwargs={
"api_key": "old",
"base_url": "https://a.example/v1",
"default_headers": {"Authorization": "old-secret"},
},
_replace_primary_openai_client=MagicMock(),
)
agent._apply_client_headers_for_base_url = MethodType(
AIAgent._apply_client_headers_for_base_url,
agent,
)
agent._apply_user_default_headers = MethodType(
AIAgent._apply_user_default_headers,
agent,
)
agent._reapply_route_client_config = MethodType(
AIAgent._reapply_route_client_config,
agent,
)
entry = SimpleNamespace(
runtime_api_key="new",
access_token="",
runtime_base_url="https://b.example/v1",
base_url="https://b.example/v1",
)
config = {
"model": {
"default_headers": {"Authorization": "global-secret"},
},
"custom_providers": [
{
"name": "b",
"base_url": "https://b.example/v1",
"extra_headers": {"X-Route": "b"},
}
],
}
with (
patch("hermes_cli.config.load_config_readonly", return_value=config),
patch(
"hermes_cli.config.get_compatible_custom_providers",
return_value=config["custom_providers"],
),
):
AIAgent._swap_credential(agent, entry)
headers = agent._client_kwargs["default_headers"]
assert "Authorization" not in headers
assert headers["X-Route"] == "b"