Files
hermes-agent/tests/agent/test_credential_pool.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

2155 lines
77 KiB
Python

"""Tests for multi-credential runtime pooling and rotation."""
from __future__ import annotations
import base64
import json
import time
from datetime import datetime, timezone
import pytest
def _write_auth_store(tmp_path, payload: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
def _jwt_with_claims(claims: dict) -> str:
def _part(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Prevent auto-seeding from Codex CLI tokens on the host
monkeypatch.setattr(
"hermes_cli.auth._import_codex_cli_tokens",
lambda: None,
)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-1",
"label": "weekly-reset",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "tok-1",
"last_status": "exhausted",
"last_status_at": time.time() - 7200,
"last_error_code": 429,
"last_error_reason": "device_code_exhausted",
"last_error_reset_at": time.time() + 7 * 24 * 60 * 60,
}
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
assert pool.has_available() is False
assert pool.select() is None
def test_billing_rotation_marks_all_entries_sharing_failed_key(tmp_path, monkeypatch):
"""A 402 must exhaust every pool entry backed by the same API key.
Regression: the same key can back more than one pool entry — e.g. an
explicit pool entry plus a ``model_config`` entry auto-seeded from
``model.api_key`` (both carry the identical ``runtime_api_key``). When
``mark_exhausted_and_rotate`` is called with ``api_key_hint`` it matched
only the *first* such entry, leaving the sibling OK. ``_select_unlocked()``
then kept handing back the same depleted key, so the billing-recovery
``continue`` loop in the conversation retry path never converged — the
request hung ~2.5min until the client disconnected, with no 402 ever
surfaced to the user. All entries sharing the failed key must be
exhausted so the pool reaches "no available entries" and the error
propagates immediately.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
shared_key = "sk-deepseek-shared"
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"custom": [
{
"id": "cred-explicit",
"label": "520555",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": shared_key,
"base_url": "https://api.deepseek.com",
},
{
"id": "cred-model-config",
"label": "model_config",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": shared_key,
"base_url": "https://api.deepseek.com",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_EXHAUSTED
pool = load_pool("custom")
# First 402 on the shared key: rotation must NOT hand back a sibling
# entry that wraps the same depleted key — it must converge to None.
next_entry = pool.mark_exhausted_and_rotate(
status_code=402,
api_key_hint=shared_key,
)
assert next_entry is None
# Both entries are now exhausted (not just the first match).
statuses = {entry.id: entry.last_status for entry in pool.entries()}
assert statuses["cred-explicit"] == STATUS_EXHAUSTED
assert statuses["cred-model-config"] == STATUS_EXHAUSTED
def test_stale_credential_id_prefers_api_key_hint(tmp_path, monkeypatch):
"""#79156: disagreeing credential_id + api_key_hint must mark the key.
After per-turn env refresh rewrites ``api_key`` without rebinding the
pool entry id, recovery still passes the stale id of the healthy
fallback together with the primary key that actually failed. The
healthy key must not inherit the primary's 429.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setattr("agent.anthropic_credentials.read_claude_code_credentials", lambda: None)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"anthropic": [
{
"id": "cred-primary",
"label": "primary",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-api-primary",
},
{
"id": "cred-backup",
"label": "backup",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": "sk-ant-api-backup",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_EXHAUSTED
pool = load_pool("anthropic")
next_entry = pool.mark_exhausted_and_rotate(
status_code=429,
api_key_hint="sk-ant-api-primary",
credential_id="cred-backup", # stale id after env refresh (#79156)
)
statuses = {entry.id: entry.last_status for entry in pool.entries()}
assert statuses["cred-primary"] == STATUS_EXHAUSTED
assert statuses["cred-backup"] != STATUS_EXHAUSTED
# Rotation hands the healthy backup (or None if selection prefers next).
if next_entry is not None:
assert next_entry.id == "cred-backup"
assert next_entry.runtime_api_key == "sk-ant-api-backup"
def test_unmatched_api_key_hint_rotates_without_benching_innocent_key(tmp_path, monkeypatch):
"""An api_key_hint matching no entry must not quarantine a healthy key.
Regression: when the hint was unmatched (key rotated away, or a wrapper
whose runtime key differs), mark_exhausted_and_rotate fell through to
current()/_select_unlocked() — on a freshly loaded pool that selects the
NEXT healthy key and benched it for the full cooldown TTL, punishing an
innocent credential. Now it rotates without marking anything.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Keep the dev machine's live ~/.claude credentials from seeding a
# claude_code singleton entry into this pool (same isolation as the
# other anthropic pool tests in this file).
monkeypatch.setattr("agent.anthropic_credentials.read_claude_code_credentials", lambda: None)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"anthropic": [
{
"id": "cred-1",
"label": "primary",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-api-primary",
},
{
"id": "cred-2",
"label": "secondary",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": "sk-ant-api-secondary",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_DEAD, STATUS_EXHAUSTED
# Freshly loaded pool: current() is None, exactly the shape of the bug.
pool = load_pool("anthropic")
next_entry = pool.mark_exhausted_and_rotate(
status_code=429,
api_key_hint="sk-ant-api-rotated-away",
)
# A fresh selection is still handed back so the caller can retry...
assert next_entry is not None
# ...but no credential was benched, in memory or on disk.
assert all(
entry.last_status not in (STATUS_EXHAUSTED, STATUS_DEAD)
for entry in pool.entries()
)
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
for persisted in auth_payload["credential_pool"]["anthropic"]:
assert persisted.get("last_status") not in (STATUS_EXHAUSTED, STATUS_DEAD)
assert persisted.get("last_error_code") is None
def test_token_invalidated_marks_credential_dead(tmp_path, monkeypatch):
"""OpenAI Codex token_invalidated must mark the credential DEAD, not exhausted.
Regression for #32849: when an OAuth credential is revoked upstream, the
1-hour exhausted TTL means it re-enters rotation every hour and fails
again with the same 401 — surfacing as "Failed to generate context
summary" on context compression. Terminal OAuth failures should never
auto-recover.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-dead",
"label": "revoked",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "revoked-at",
"refresh_token": "revoked-rt",
},
{
"id": "cred-ok",
"label": "healthy",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "healthy-at",
"refresh_token": "healthy-rt",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_DEAD
pool = load_pool("openai-codex")
assert pool.select().id == "cred-dead"
# Simulate the exact OpenAI Codex 401 token_invalidated response shape.
next_entry = pool.mark_exhausted_and_rotate(
status_code=401,
error_context={
"reason": "token_invalidated",
"message": "Your authentication token has been invalidated. Please try signing in again.",
},
)
# Rotation still works — we hand off to the healthy credential.
assert next_entry is not None
assert next_entry.id == "cred-ok"
# The revoked credential is now permanently marked DEAD.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
persisted = auth_payload["credential_pool"]["openai-codex"][0]
assert persisted["last_status"] == STATUS_DEAD
assert persisted["last_error_code"] == 401
assert persisted["last_error_reason"] == "token_invalidated"
def test_dead_credential_never_re_enters_rotation_after_ttl(tmp_path, monkeypatch):
"""A DEAD credential must stay excluded regardless of how much time passes.
The exhausted TTL clears entries after 5 min (401) / 1 hour (429).
A DEAD credential has no recovery TTL — it stays dead until either
(a) an explicit re-auth write-side sync rewrites the tokens, or
(b) the manual-prune TTL elapses (covered by separate tests below).
This test verifies the core invariant in the recent-entry window.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# DEAD entry from 2 hours ago — well past the exhausted TTLs (5min/1h)
# but well within the 24h manual-prune window.
two_hours_ago = time.time() - (2 * 3600)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-dead",
"label": "revoked",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "revoked-at",
"refresh_token": "revoked-rt",
"last_status": "dead",
"last_status_at": two_hours_ago,
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
{
"id": "cred-ok",
"label": "healthy",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "healthy-at",
"refresh_token": "healthy-rt",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_DEAD
pool = load_pool("openai-codex")
selected = pool.select()
# Should skip the dead entry and pick the healthy one — even though
# the dead entry has priority 0 (would normally be picked first) and
# plenty of time has passed since it was marked dead.
assert selected is not None
assert selected.id == "cred-ok"
# The DEAD entry is still marked dead on disk — not cleared by TTL.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
dead_entry = next(e for e in auth_payload["credential_pool"]["openai-codex"]
if e["id"] == "cred-dead")
assert dead_entry["last_status"] == STATUS_DEAD
def test_429_rate_limit_still_uses_exhausted_not_dead(tmp_path, monkeypatch):
"""429 rate limits must NOT be treated as terminal.
They should keep the existing 1-hour TTL cooldown semantics so the
credential re-enters rotation once the rate window resets.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-1",
"label": "primary",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "at-1",
"refresh_token": "rt-1",
},
{
"id": "cred-2",
"label": "secondary",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "at-2",
"refresh_token": "rt-2",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_EXHAUSTED
pool = load_pool("openai-codex")
assert pool.select().id == "cred-1"
next_entry = pool.mark_exhausted_and_rotate(
status_code=429,
error_context={"reason": "rate_limit_exceeded", "message": "Rate limit exceeded"},
)
assert next_entry is not None
assert next_entry.id == "cred-2"
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
persisted = auth_payload["credential_pool"]["openai-codex"][0]
# 429 stays exhausted (transient) — NOT dead.
assert persisted["last_status"] == STATUS_EXHAUSTED
assert persisted["last_error_code"] == 429
def test_generic_401_without_terminal_reason_still_uses_exhausted(tmp_path, monkeypatch):
"""A 401 with no specific code/reason should keep TTL semantics.
Only specific terminal reasons (token_invalidated, token_revoked, etc.)
transition to DEAD. A generic 401 might be a transient server-side
issue worth retrying after the 5-min TTL.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-1",
"label": "primary",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "at-1",
"refresh_token": "rt-1",
},
{
"id": "cred-2",
"label": "secondary",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "at-2",
"refresh_token": "rt-2",
},
]
},
},
)
from agent.credential_pool import load_pool, STATUS_EXHAUSTED
pool = load_pool("openai-codex")
pool.select()
# 401 with no specific reason — stays exhausted, NOT dead.
pool.mark_exhausted_and_rotate(
status_code=401,
error_context={"message": "Unauthorized"},
)
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
persisted = auth_payload["credential_pool"]["openai-codex"][0]
assert persisted["last_status"] == STATUS_EXHAUSTED
assert persisted["last_error_code"] == 401
def test_dead_manual_entry_pruned_after_24h(tmp_path, monkeypatch):
"""A DEAD manual entry is removed from the pool after the prune TTL.
Manual entries (``manual:*``) are independent credentials with no
singleton to re-seed from, so we can clean them up after a quiet
window without losing recoverability — the user can always re-add
via ``hermes auth add``.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# DEAD entry from > 24h ago
long_ago = time.time() - (25 * 3600)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openai-codex": [
{
"id": "cred-old-dead",
"label": "ancient-dead",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "stale",
"refresh_token": "stale",
"last_status": "dead",
"last_status_at": long_ago,
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
{
"id": "cred-ok",
"label": "healthy",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "healthy-at",
"refresh_token": "healthy-rt",
},
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
# Trigger _available_entries via select; that runs the prune.
selected = pool.select()
assert selected is not None
assert selected.id == "cred-ok"
# On-disk pool should have the dead entry removed.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
persisted = auth_payload["credential_pool"]["openai-codex"]
assert len(persisted) == 1
assert persisted[0]["id"] == "cred-ok"
def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-seeded")
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.source == "env:OPENROUTER_API_KEY"
assert entry.access_token == "sk-or-seeded"
def test_load_pool_does_not_persist_env_seeded_secret_value(tmp_path, monkeypatch):
"""Runtime env keys may be used in memory but must not land in auth.json."""
sentinel = "S3NTINEL_DO_NOT_PERSIST_OPENROUTER"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.source == "env:OPENROUTER_API_KEY"
assert entry.access_token == sentinel
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
assert sentinel not in auth_text
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
assert persisted["source"] == "env:OPENROUTER_API_KEY"
assert persisted["label"] == "OPENROUTER_API_KEY"
assert persisted["auth_type"] == "api_key"
assert persisted["priority"] == 0
assert "access_token" not in persisted
assert persisted["secret_fingerprint"].startswith("sha256:")
def test_load_pool_collapses_duplicate_env_rows_to_active_key(tmp_path, monkeypatch):
"""One env source is one credential, even if auth.json contains stale duplicates."""
key = "sk-or-active-main-key"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("OPENROUTER_API_KEY", key)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openrouter": [
{
"id": "current-row",
"label": "OPENROUTER_API_KEY",
"auth_type": "api_key",
"priority": 0,
"source": "env:OPENROUTER_API_KEY",
},
{
"id": "stale-duplicate",
"label": "OPENROUTER_API_KEY",
"auth_type": "api_key",
"priority": 1,
"source": "env:OPENROUTER_API_KEY",
},
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
assert [(entry.id, entry.runtime_api_key) for entry in pool.entries()] == [
("current-row", key)
]
persisted = json.loads((tmp_path / "hermes" / "auth.json").read_text())
assert [entry["id"] for entry in persisted["credential_pool"]["openrouter"]] == [
"current-row"
]
def test_credential_pool_never_selects_empty_borrowed_entry():
from agent.credential_pool import CredentialPool, PooledCredential
pool = CredentialPool(
"openrouter",
[
PooledCredential(
provider="openrouter",
id="metadata-only",
label="OPENROUTER_API_KEY",
auth_type="api_key",
priority=0,
source="env:OPENROUTER_API_KEY",
access_token="",
)
],
)
assert pool.select() is None
assert pool.acquire_lease() is None
def test_load_pool_persists_bitwarden_origin_metadata_without_secret(tmp_path, monkeypatch):
"""Bitwarden-injected env vars retain source metadata but not raw values."""
sentinel = "S3NTINEL_DO_NOT_PERSIST_BITWARDEN"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
monkeypatch.setattr(
"hermes_cli.env_loader.get_secret_source",
lambda env_var: "bitwarden" if env_var == "OPENROUTER_API_KEY" else None,
)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.access_token == sentinel
assert entry.source == "env:OPENROUTER_API_KEY"
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
assert sentinel not in auth_text
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
assert persisted["source"] == "env:OPENROUTER_API_KEY"
assert persisted["secret_source"] == "bitwarden"
assert "access_token" not in persisted
def test_load_pool_sanitizes_legacy_raw_borrowed_entry_when_value_unchanged(tmp_path, monkeypatch):
"""Existing raw env-seeded pool entries are rewritten even if the env value matches."""
sentinel = "S3NTINEL_DO_NOT_PERSIST_LEGACY_RAW"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openrouter": [
{
"id": "legacy-env",
"label": "OPENROUTER_API_KEY",
"auth_type": "api_key",
"priority": 0,
"source": "env:OPENROUTER_API_KEY",
"access_token": sentinel,
"base_url": "https://openrouter.ai/api/v1",
}
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.access_token == sentinel
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
assert sentinel not in auth_text
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
assert persisted["id"] == "legacy-env"
assert "access_token" not in persisted
assert persisted["secret_fingerprint"].startswith("sha256:")
def test_pooled_credential_to_dict_strips_borrowed_secret_fields():
from agent.credential_pool import PooledCredential
sentinel = "S3NTINEL_DO_NOT_PERSIST_TO_DICT"
credential = PooledCredential(
provider="openrouter",
id="borrowed-1",
label="vault-ref",
auth_type="api_key",
priority=3,
source="vault:openrouter/api-key",
access_token=sentinel,
refresh_token=f"refresh-{sentinel}",
agent_key=f"agent-{sentinel}",
request_count=7,
last_status="ok",
extra={
"api_key": f"extra-{sentinel}",
"client_secret": f"client-{sentinel}",
"secret_key": f"secret-key-{sentinel}",
"authToken": f"auth-token-{sentinel}",
"refreshToken": f"camel-refresh-{sentinel}",
"authorization": f"Bearer {sentinel}",
"tokens": {"access_token": f"nested-{sentinel}"},
"token_type": "Bearer",
"scope": "inference",
},
)
payload = credential.to_dict()
serialized = json.dumps(payload)
assert sentinel not in serialized
assert "access_token" not in payload
assert "refresh_token" not in payload
assert "agent_key" not in payload
assert "api_key" not in payload
assert "client_secret" not in payload
assert "secret_key" not in payload
assert "authToken" not in payload
assert "refreshToken" not in payload
assert "authorization" not in payload
assert "tokens" not in payload
assert payload["source"] == "vault:openrouter/api-key"
assert payload["label"] == "vault-ref"
assert payload["request_count"] == 7
assert payload["token_type"] == "Bearer"
assert payload["scope"] == "inference"
assert payload["secret_fingerprint"].startswith("sha256:")
@pytest.mark.parametrize("source", [
"age://openrouter/api-key",
"systemd",
"keyring",
"1password",
"pass",
"sops",
"future_secret_store:openrouter",
])
def test_borrowed_source_variants_strip_secret_fields(source):
from agent.credential_pool import PooledCredential
sentinel = f"S3NTINEL_DO_NOT_PERSIST_{source.replace(':', '_').replace('/', '_')}"
credential = PooledCredential(
provider="openrouter",
id="borrowed-variant",
label="borrowed",
auth_type="api_key",
priority=0,
source=source,
access_token=sentinel,
refresh_token=f"refresh-{sentinel}",
)
payload = credential.to_dict()
serialized = json.dumps(payload)
assert sentinel not in serialized
assert "access_token" not in payload
assert "refresh_token" not in payload
assert payload["source"] == source
assert payload["secret_fingerprint"].startswith("sha256:")
def test_write_credential_pool_sanitizes_borrowed_payload_at_disk_boundary(tmp_path, monkeypatch):
"""Direct dictionary callers cannot bypass the borrowed-secret guard."""
sentinel = "S3NTINEL_DO_NOT_PERSIST_DIRECT_WRITE"
manual_secret = "MANUAL_SECRET_STAYS_PERSISTABLE"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from hermes_cli.auth import write_credential_pool
write_credential_pool("openrouter", [
{
"id": "borrowed-1",
"label": "systemd-ref",
"auth_type": "api_key",
"priority": 0,
"source": "systemd://hermes/openrouter",
"access_token": sentinel,
"refresh_token": f"refresh-{sentinel}",
"agent_key": f"agent-{sentinel}",
"api_key": f"extra-{sentinel}",
},
{
"id": "manual-1",
"label": "manual",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": manual_secret,
},
])
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
assert sentinel not in auth_text
assert manual_secret in auth_text
entries = json.loads(auth_text)["credential_pool"]["openrouter"]
borrowed, manual = entries
assert borrowed["source"] == "systemd://hermes/openrouter"
assert "access_token" not in borrowed
assert "refresh_token" not in borrowed
assert "agent_key" not in borrowed
assert "api_key" not in borrowed
assert borrowed["secret_fingerprint"].startswith("sha256:")
assert manual["access_token"] == manual_secret
def test_write_credential_pool_treats_unowned_oauth_source_as_borrowed(tmp_path, monkeypatch):
sentinel = "S3NTINEL_DO_NOT_PERSIST_UNOWNED_OAUTH"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from hermes_cli.auth import write_credential_pool
write_credential_pool("openrouter", [
{
"id": "unowned-oauth",
"label": "unowned-oauth",
"auth_type": "oauth",
"priority": 0,
"source": "oauth",
"access_token": sentinel,
"refresh_token": f"refresh-{sentinel}",
}
])
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
assert sentinel not in auth_text
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
assert persisted["source"] == "oauth"
assert "access_token" not in persisted
assert "refresh_token" not in persisted
assert persisted["secret_fingerprint"].startswith("sha256:")
def test_write_credential_pool_preserves_known_provider_owned_oauth_state(tmp_path, monkeypatch):
sentinel = "PROVIDER_OWNED_DEVICE_CODE_STAYS_PERSISTABLE"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from hermes_cli.auth import write_credential_pool
write_credential_pool("nous", [
{
"id": "nous-device",
"label": "device-code",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": sentinel,
"refresh_token": f"refresh-{sentinel}",
"agent_key": f"agent-{sentinel}",
}
])
persisted = json.loads((tmp_path / "hermes" / "auth.json").read_text())["credential_pool"]["nous"][0]
assert persisted["access_token"] == sentinel
assert persisted["refresh_token"] == f"refresh-{sentinel}"
assert persisted["agent_key"] == f"agent-{sentinel}"
def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):
"""Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited
from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when
seeding the credential pool. Before the fix, `get_env_value()` preferred
os.environ and silently wrote the stale value into auth.json, causing
persistent 401 errors after key rotation.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Simulate the bug: parent shell exported a stale test key
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-STALE-from-shell")
# User edited ~/.hermes/.env with the fresh key
(hermes_home / ".env").write_text(
"OPENROUTER_API_KEY=sk-or-FRESH-from-dotenv\n"
)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.source == "env:OPENROUTER_API_KEY"
# The fresh key from .env must win over the stale shell export
assert entry.access_token == "sk-or-FRESH-from-dotenv", (
f"Expected .env to win, got {entry.access_token!r}"
)
def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypatch):
"""When ~/.hermes/.env does not define OPENROUTER_API_KEY (typical Docker /
K8s / systemd deployment), seeding must still pick up the key from
os.environ. Guards against regressions that would break production
deployments relying on runtime-injected env vars.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-from-runtime-env")
# .env exists but does not define OPENROUTER_API_KEY
(hermes_home / ".env").write_text("SOME_OTHER_VAR=unrelated\n")
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.access_token == "sk-or-from-runtime-env"
def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat()
token = _jwt_with_claims({
"sub": "test-user",
"scope": ["inference:invoke"],
"exp": int(time.time() + 3600),
})
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:invoke",
"access_token": token,
"refresh_token": "refresh-token",
"expires_at": expires_at,
"agent_key": token,
"agent_key_expires_at": expires_at,
}
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
entry = pool.select()
assert entry is not None
assert entry.source == "device_code"
assert entry.agent_key == token
assert entry.runtime_api_key == token
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
pool_entry = auth_payload["credential_pool"]["nous"][0]
assert pool_entry["agent_key"] == token
assert pool_entry["agent_key_expires_at"] == expires_at
def test_nous_runtime_api_key_rejects_opaque_agent_key():
from agent.credential_pool import PooledCredential
entry = PooledCredential(
provider="nous",
id="nous-opaque",
label="opaque",
auth_type="oauth",
priority=0,
source="device_code",
access_token="opaque-access-token",
refresh_token="refresh-token",
agent_key="opaque-agent-key",
agent_key_expires_at=datetime.fromtimestamp(
time.time() + 3600,
tz=timezone.utc,
).isoformat(),
extra={"scope": "inference:invoke"},
)
assert entry.runtime_api_key == ""
def test_load_pool_api_key_path_skips_oauth_autodiscovery(tmp_path, monkeypatch):
"""API-key auth path: autodiscovered OAuth creds must NOT be seeded.
When the user picks "Anthropic API key" at `hermes setup`,
`save_anthropic_api_key()` writes ANTHROPIC_API_KEY and zeros
ANTHROPIC_TOKEN. That env-var pattern is the explicit signal that the
user opted into the API-key path and explicitly OUT of the OAuth
masquerade (Claude Code identity injection + `mcp_` tool-name rewrite
+ claude-cli user-agent). Autodiscovered Claude Code / Hermes PKCE
tokens from other tools' credential files must NOT be silently mixed
into the anthropic pool — otherwise rotation on a 401/429 could flip
the session onto OAuth credentials mid-conversation.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-explicit-user-key")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
pkce_called = {"n": 0}
cc_called = {"n": 0}
def _fake_pkce():
pkce_called["n"] += 1
return {
"accessToken": "sk-ant-oat01-pkce-token",
"refreshToken": "pkce-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
def _fake_cc():
cc_called["n"] += 1
return {
"accessToken": "sk-ant-oat01-claude-code-token",
"refreshToken": "cc-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
monkeypatch.setattr("agent.anthropic_credentials.read_hermes_oauth_credentials", _fake_pkce)
monkeypatch.setattr("agent.anthropic_credentials.read_claude_code_credentials", _fake_cc)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")
sources = {entry.source for entry in pool.entries()}
# Only the explicit API-key entry should be in the pool.
assert sources == {"env:ANTHROPIC_API_KEY"}, f"got {sources}"
# And we should not have even called the autodiscovery readers.
assert pkce_called["n"] == 0
assert cc_called["n"] == 0
def test_load_pool_api_key_path_prunes_stale_oauth_entries(tmp_path, monkeypatch):
"""Switching OAuth -> API key must prune stale OAuth entries from auth.json.
Without this, a user who logs into OAuth (seeding `claude_code` or
`hermes_pkce` into auth.json) and later switches to the API key at
`hermes setup` would still have those OAuth entries dormant on disk.
Pool rotation on a transient 401 could revive them and flip the
session onto the OAuth masquerade.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-explicit-user-key")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
# Plant a stale claude_code entry in the on-disk pool (as if a previous
# OAuth session seeded it).
_write_auth_store(
tmp_path,
{
"version": 1,
"providers": {},
"credential_pool": {
"anthropic": [
{
"id": "stale1",
"source": "claude_code",
"auth_type": "oauth",
"access_token": "sk-ant-oat01-stale-claude-code",
"refresh_token": "stale-refresh",
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
"priority": 0,
"label": "stale-claude-code",
"request_count": 0,
},
],
},
},
)
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
monkeypatch.setattr("agent.anthropic_credentials.read_hermes_oauth_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_credentials.read_claude_code_credentials", lambda: None)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")
sources = {entry.source for entry in pool.entries()}
# Stale claude_code entry must be gone, API key must be present.
assert "claude_code" not in sources
assert "env:ANTHROPIC_API_KEY" in sources
def test_load_pool_oauth_path_still_autodiscovers(tmp_path, monkeypatch):
"""OAuth path: ANTHROPIC_TOKEN set, autodiscovery still fires.
Regression guard: the API-key gate must not affect users who chose the
OAuth path at `hermes setup`. When ANTHROPIC_TOKEN is set (and
ANTHROPIC_API_KEY is empty), autodiscovered Claude Code creds should
still be seeded into the pool as before.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-explicit-oauth-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
monkeypatch.setattr(
"agent.anthropic_credentials.read_hermes_oauth_credentials",
lambda: None,
)
monkeypatch.setattr(
"agent.anthropic_credentials.read_claude_code_credentials",
lambda: {
"accessToken": "sk-ant-oat01-autodiscovered-cc",
"refreshToken": "cc-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
},
)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")
sources = {entry.source for entry in pool.entries()}
# Both env OAuth token and autodiscovered Claude Code creds should be there.
assert "env:ANTHROPIC_TOKEN" in sources
assert "claude_code" in sources
def test_least_used_strategy_selects_lowest_count(tmp_path, monkeypatch):
"""least_used strategy should select the credential with the lowest request_count."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setattr(
"agent.credential_pool.get_pool_strategy",
lambda _provider: "least_used",
)
monkeypatch.setattr(
"agent.credential_pool._seed_from_singletons",
lambda provider, entries: (False, set()),
)
monkeypatch.setattr(
"agent.credential_pool._seed_from_env",
lambda provider, entries: (False, set()),
)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"openrouter": [
{
"id": "key-a",
"label": "heavy",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-heavy",
"request_count": 100,
},
{
"id": "key-b",
"label": "light",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": "sk-or-light",
"request_count": 10,
},
{
"id": "key-c",
"label": "medium",
"auth_type": "api_key",
"priority": 2,
"source": "manual",
"access_token": "sk-or-medium",
"request_count": 50,
},
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("openrouter")
entry = pool.select()
assert entry is not None
assert entry.id == "key-b"
assert entry.access_token == "sk-or-light"
def test_custom_endpoint_pool_seeds_from_config(tmp_path, monkeypatch):
"""Verify seeding from custom_providers api_key in config.yaml."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1})
# Write config.yaml with a custom_providers entry
config_path = tmp_path / "hermes" / "config.yaml"
import hermes_yaml as yaml
config_path.write_text(yaml.safe_dump({
"custom_providers": [
{
"name": "Together.ai",
"base_url": "https://api.together.ai/v1",
"api_key": "sk-config-seeded",
}
]
}))
from agent.credential_pool import load_pool
pool = load_pool("custom:together.ai")
assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
assert entries[0].access_token == "sk-config-seeded"
assert entries[0].source == "config:Together.ai"
def test_custom_endpoint_pool_seeds_from_model_config(tmp_path, monkeypatch):
"""Verify seeding from model.api_key when model.provider=='custom' and base_url matches."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1})
import hermes_yaml as yaml
config_path = tmp_path / "hermes" / "config.yaml"
config_path.write_text(yaml.safe_dump({
"custom_providers": [
{
"name": "Together.ai",
"base_url": "https://api.together.ai/v1",
}
],
"model": {
"provider": "custom",
"base_url": "https://api.together.ai/v1",
"api_key": "sk-model-key",
},
}))
from agent.credential_pool import load_pool
pool = load_pool("custom:together.ai")
assert pool.has_credentials()
entries = pool.entries()
# Should have the model_config entry
model_entries = [e for e in entries if e.source == "model_config"]
assert len(model_entries) == 1
assert model_entries[0].access_token == "sk-model-key"
# "custom:empty" not included because it's empty
def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_path, monkeypatch):
"""Claude Code credentials must not be auto-seeded when the user never selected anthropic."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
# Claude Code credentials exist on disk
monkeypatch.setattr(
"agent.anthropic_credentials.read_claude_code_credentials",
lambda: {"accessToken": "sk-ant...oken", "refreshToken": "rt", "expiresAt": 9999999999999},
)
monkeypatch.setattr(
"agent.anthropic_credentials.read_hermes_oauth_credentials",
lambda: None,
)
# User configured kimi-coding, NOT anthropic
monkeypatch.setattr(
"hermes_cli.auth.is_provider_explicitly_configured",
lambda pid: pid == "kimi-coding",
)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")
# Should NOT have seeded the claude_code entry
assert pool.entries() == []
def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
"""Copilot credentials from `gh auth token` should be seeded into the pool."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
assert entries[0].source == "gh_cli"
assert entries[0].access_token == "gho_fake_token_abc123"
assert entries[0].base_url == "https://api.githubcopilot.com"
def test_load_pool_skips_exchange_for_suppressed_copilot(tmp_path, monkeypatch):
"""A suppressed copilot source must NOT run the token exchange.
Regression test: the suppression gate used to sit AFTER
``get_copilot_api_token`` (which retries 3x with backoff, ~13s worst
case), so every pool load — model picker open, /model, agent startup —
burned the full exchange dead time for a source the user had already
removed with ``hermes auth remove copilot gh_cli``. The gate must run
BEFORE the network call.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["gh_cli"]},
},
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
exchange_called = False
def _boom(token):
nonlocal exchange_called
exchange_called = True
raise AssertionError("exchange must not run for a suppressed source")
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
_boom,
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert not exchange_called
assert not pool.has_credentials()
assert pool.entries() == []
def test_load_pool_respects_env_var_copilot_suppression(tmp_path, monkeypatch):
"""Suppressing env:GH_TOKEN must gate a GH_TOKEN-sourced token.
Regression test for the source_name classification: a substring match
(``"gh" in source.lower()``) classified GH_TOKEN/GITHUB_TOKEN as gh_cli,
so a user's env-var-specific suppression was silently bypassed and the
exchange ran anyway.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["env:GH_TOKEN"]},
},
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_env", "GH_TOKEN"),
)
exchange_called = False
def _boom(token):
nonlocal exchange_called
exchange_called = True
raise AssertionError("exchange must not run for a suppressed env source")
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
_boom,
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert not exchange_called
assert pool.entries() == []
def test_load_pool_gh_cli_suppression_does_not_block_env_tokens(tmp_path, monkeypatch):
"""Suppressing gh_cli must NOT swallow an env-var-sourced token.
The inverse of the substring bug: GH_TOKEN misclassified as gh_cli meant
suppressing the CLI path also silently dropped env tokens.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["gh_cli"]},
},
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_env", "GH_TOKEN"),
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
lambda token: ("capi_exchanged_token", None),
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert [e.source for e in pool.entries()] == ["env:GH_TOKEN"]
def test_load_pool_skips_resolve_when_all_copilot_sources_suppressed(tmp_path, monkeypatch):
"""With every copilot source suppressed, resolve_copilot_token (which
shells out to ``gh auth token``) must not run at all."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from hermes_cli.copilot_auth import COPILOT_ENV_VARS
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {
"copilot": ["gh_cli"] + [f"env:{v}" for v in COPILOT_ENV_VARS],
},
},
)
def _boom():
raise AssertionError("resolve_copilot_token must not run when all sources are suppressed")
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", _boom)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert pool.entries() == []
def test_load_pool_copilot_exchange_only_when_selected_and_warns_once(tmp_path, monkeypatch, caplog):
"""An ambient gh-CLI Copilot credential is seeded without the token exchange (and without the
'degraded to RAW token' warning) until copilot is actually selected; once selected, the
degradation is reported once per token, not on every pool load (#114740)."""
import logging
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
from agent.credential_pool import _reset_copilot_raw_degradation_warned, load_pool
_reset_copilot_raw_degradation_warned()
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("gho_raw_initial", "gh auth token"))
exchanges = []
def degraded_exchange(token):
exchanges.append(token)
return token, None # exchange unavailable -> RAW token, no enterprise URL
monkeypatch.setattr("hermes_cli.copilot_auth.get_copilot_api_token", degraded_exchange)
def degradation_warnings():
return [r for r in caplog.records if "Copilot token exchange degraded to RAW token" in r.message]
with caplog.at_level(logging.WARNING, logger="agent.credential_pool"):
# Main provider is deepseek; copilot is merely discovered via `gh auth token`.
(tmp_path / "hermes" / "config.yaml").write_text("model:\n provider: deepseek\n default: deepseek-chat\n", encoding="utf-8")
pool = load_pool("copilot")
load_pool("copilot")
assert exchanges == [] and degradation_warnings() == []
assert [e.access_token for e in pool.entries()] == ["gho_raw_initial"] # credential still listed
# The user selects copilot for one auxiliary task: the exchange runs, the degradation is
# reported exactly once across repeated loads.
(tmp_path / "hermes" / "config.yaml").write_text(
"model:\n provider: deepseek\n default: deepseek-chat\nauxiliary:\n approval:\n provider: copilot\n", encoding="utf-8")
from hermes_cli import config as _cfg
_cfg._LOAD_CONFIG_CACHE.clear()
_cfg._RAW_CONFIG_CACHE.clear() # same-second rewrite: the mtime signature may not change
load_pool("copilot")
load_pool("copilot")
assert len(exchanges) == 2 and len(degradation_warnings()) == 1
# A different token is a different degradation: warned again, once.
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("gho_raw_rotated", "gh auth token"))
load_pool("copilot")
assert len(degradation_warnings()) == 2
def test_load_pool_seeds_qwen_oauth_via_cli_tokens(tmp_path, monkeypatch):
"""Qwen OAuth credentials from ~/.qwen/oauth_creds.json should be seeded into the pool."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
monkeypatch.setattr(
"hermes_cli.auth.resolve_qwen_runtime_credentials",
lambda **kw: {
"provider": "qwen-oauth",
"base_url": "https://portal.qwen.ai/v1",
"api_key": "qwen_fake_token_xyz",
"source": "qwen-cli",
"expires_at_ms": 1900000000000,
"auth_file": str(tmp_path / ".qwen" / "oauth_creds.json"),
},
)
from agent.credential_pool import load_pool
pool = load_pool("qwen-oauth")
assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
assert entries[0].source == "qwen-cli"
assert entries[0].access_token == "qwen_fake_token_xyz"
def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch):
"""Qwen OAuth pool should be empty when no CLI credentials exist."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
from hermes_cli.auth import AuthError
monkeypatch.setattr(
"hermes_cli.auth.resolve_qwen_runtime_credentials",
lambda **kw: (_ for _ in ()).throw(
AuthError("Qwen CLI credentials not found.", provider="qwen-oauth", code="qwen_auth_missing")
),
)
from agent.credential_pool import load_pool
pool = load_pool("qwen-oauth")
assert not pool.has_credentials()
assert pool.entries() == []
def test_nous_seed_from_singletons_preserves_obtained_at_timestamps(tmp_path, monkeypatch):
"""Regression test for #15099 secondary issue.
When ``_seed_from_singletons`` materialises a device_code pool entry from
the ``providers.nous`` singleton, it must carry the mint/refresh
timestamps (``obtained_at``, ``agent_key_obtained_at``, ``expires_in``,
etc.) into the pool entry. Without them, freshness-sensitive consumers
(self-heal hooks, pool pruning by age) treat just-minted credentials as
older than they actually are and evict them.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"providers": {
"nous": {
"access_token": "at_XXXXXXXX",
"refresh_token": "rt_YYYYYYYY",
"client_id": "hermes-cli",
"portal_base_url": "https://portal.nousresearch.com",
"inference_base_url": "https://inference.nousresearch.com/v1",
"token_type": "Bearer",
"scope": "openid profile",
"obtained_at": "2026-04-24T10:00:00+00:00",
"expires_at": "2026-04-24T11:00:00+00:00",
"expires_in": 3600,
"agent_key": "sk-nous-AAAA",
"agent_key_id": "ak_123",
"agent_key_expires_at": "2026-04-25T10:00:00+00:00",
"agent_key_expires_in": 86400,
"agent_key_reused": False,
"agent_key_obtained_at": "2026-04-24T10:00:05+00:00",
"tls": {"insecure": False, "ca_bundle": None},
},
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
entries = pool.entries()
device_entries = [e for e in entries if e.source == "device_code"]
assert len(device_entries) == 1, f"expected single device_code entry; got {len(device_entries)}"
e = device_entries[0]
# Direct dataclass fields — must survive the singleton → pool copy.
assert e.access_token == "at_XXXXXXXX"
assert e.refresh_token == "rt_YYYYYYYY"
assert e.expires_at == "2026-04-24T11:00:00+00:00"
assert e.agent_key == "sk-nous-AAAA"
assert e.agent_key_expires_at == "2026-04-25T10:00:00+00:00"
# Extra fields — this is what regressed. These must be carried through
# via ``extra`` dict or __getattr__, NOT silently dropped.
assert e.obtained_at == "2026-04-24T10:00:00+00:00", (
f"obtained_at was dropped during seed; got {e.obtained_at!r}. This breaks "
f"downstream pool-freshness consumers (#15099)."
)
assert e.agent_key_obtained_at == "2026-04-24T10:00:05+00:00"
assert e.expires_in == 3600
assert e.agent_key_id == "ak_123"
assert e.agent_key_expires_in == 86400
assert e.agent_key_reused is False
# ── PR #10160 salvage: Nous OAuth cross-process sync tests ─────────────────
# ── OpenAI Codex OAuth cross-process sync tests ────────────────────────────
# ---------------------------------------------------------------------------
# xAI OAuth terminal error quarantine
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Codex OAuth terminal error quarantine
# ---------------------------------------------------------------------------
def test_persist_preserves_concurrent_disk_only_entry(tmp_path, monkeypatch):
"""Regression for #19566: stale rotation writes keep concurrent entries."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Block external-credential autodiscovery: a real ~/.claude/.credentials.json
# on a dev machine would seed an extra claude_code entry and break the
# exact-id assertions below (passes on CI where no such file exists).
monkeypatch.setattr("agent.anthropic_credentials.read_hermes_oauth_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_credentials.read_claude_code_credentials", lambda: None)
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"anthropic": [
{
"id": "cred-A",
"label": "primary",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-A",
},
{
"id": "cred-B",
"label": "secondary",
"auth_type": "api_key",
"priority": 1,
"source": "manual",
"access_token": "sk-B",
},
]
},
},
)
from agent.credential_pool import load_pool
from hermes_cli.auth import read_credential_pool, write_credential_pool
pool = load_pool("anthropic")
assert {entry.id for entry in pool.entries()} == {"cred-A", "cred-B"}
disk_snapshot = read_credential_pool("anthropic")
disk_snapshot.append(
{
"id": "cred-C",
"label": "added-concurrently",
"auth_type": "api_key",
"priority": 2,
"source": "manual",
"access_token": "sk-C",
}
)
write_credential_pool("anthropic", disk_snapshot)
pool.mark_exhausted_and_rotate(status_code=429)
final = json.loads((tmp_path / "hermes" / "auth.json").read_text())
final_ids = [entry["id"] for entry in final["credential_pool"]["anthropic"]]
assert set(final_ids) == {"cred-A", "cred-B", "cred-C"}
persisted_a = next(
entry
for entry in final["credential_pool"]["anthropic"]
if entry["id"] == "cred-A"
)
assert persisted_a["last_status"] == "exhausted"
# ---------------------------------------------------------------------------
# _sync_anthropic_entry_from_credentials_file — parity fix tests
# ---------------------------------------------------------------------------
def _make_anthropic_claude_code_pool(tmp_path, monkeypatch, *, access_token, refresh_token, expires_at_ms=9_999_999_999_000):
"""Helper: load an Anthropic pool seeded with a single claude_code entry."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: pid == "anthropic")
monkeypatch.setattr(
"agent.anthropic_credentials.read_hermes_oauth_credentials",
lambda: None,
)
monkeypatch.setattr(
"agent.anthropic_credentials.read_claude_code_credentials",
lambda: {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms},
)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")
entry = pool.select()
assert entry is not None
assert entry.source == "claude_code"
return pool, entry
def test_sync_anthropic_entry_clears_all_error_fields(tmp_path, monkeypatch):
"""Syncing fresh tokens must clear all six error/status fields on the entry.
Before the fix, last_error_reason / last_error_message / last_error_reset_at
were left set, so a previously-exhausted entry could stay stuck even after
fresh tokens arrived from the credentials file.
"""
from dataclasses import replace as dc_replace
from agent.credential_pool import STATUS_EXHAUSTED
pool, entry = _make_anthropic_claude_code_pool(
tmp_path, monkeypatch,
access_token="stale-access",
refresh_token="stale-refresh",
)
now = time.time()
exhausted = dc_replace(
entry,
last_status=STATUS_EXHAUSTED,
last_status_at=now,
last_error_code=401,
last_error_reason="token_expired",
last_error_message="Access token has expired",
last_error_reset_at=now + 300,
)
pool._replace_entry(entry, exhausted)
monkeypatch.setattr(
"agent.anthropic_credentials.read_claude_code_credentials",
lambda: {"accessToken": "fresh-access", "refreshToken": "fresh-refresh", "expiresAt": 9_999_999_999_000},
)
synced = pool._sync_anthropic_entry_from_credentials_file(exhausted)
assert synced is not exhausted
assert synced.access_token == "fresh-access"
assert synced.last_status is None
assert synced.last_status_at is None
assert synced.last_error_code is None
assert synced.last_error_reason is None
assert synced.last_error_message is None
assert synced.last_error_reset_at is None
def _load_two_ok_pool(tmp_path, monkeypatch):
"""A pool with two OK anthropic entries, current = cred-1."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"anthropic": [
{
"id": "cred-1", "label": "primary", "auth_type": "api_key",
"priority": 0, "source": "manual", "access_token": "***",
"last_status": "ok", "last_status_at": None, "last_error_code": None,
},
{
"id": "cred-2", "label": "secondary", "auth_type": "api_key",
"priority": 1, "source": "manual", "access_token": "***",
"last_status": "ok", "last_status_at": None, "last_error_code": None,
},
]
},
},
)
from agent.credential_pool import load_pool
return load_pool("anthropic")
class TestCredentialPoolQueryLocking:
"""Public pool-state methods must run under ``self._lock``.
``has_available``/``peek``/``current``/``entries`` all touch
``self._entries`` (and ``_available_entries`` even prunes + persists),
and the management surface (``has_credentials``/``reset_statuses``/
``remove_index``/``resolve_target``/``add_entry``) reads or rebinds
``self._entries`` and persists auth.json, so they must all hold the
same lock every mutating entry point uses. A naive fix would deadlock
because the lock is non-reentrant and ``peek`` calls ``current`` +
``_available_entries``; these tests guard both the no-deadlock and the
actually-locked properties.
"""
def test_query_methods_do_not_deadlock(self, tmp_path, monkeypatch):
pool = _load_two_ok_pool(tmp_path, monkeypatch)
pool.select() # set a current entry
# peek() internally calls current() + _available_entries(); if any of
# these re-acquired the non-reentrant lock we'd hang here forever.
assert pool.current() is not None
assert pool.peek() is not None
assert pool.has_available() is True
assert pool.has_credentials() is True
assert pool.resolve_target("cred-1")[1] is not None
# (env may seed extra singleton entries; just assert ours are present)
assert {"cred-1", "cred-2"} <= {e.id for e in pool.entries()}
# try_refresh_matching's no-hint branch resolves the current entry
# while already holding the lock — must use _current_unlocked(), not
# current(), or it deadlocks on the non-reentrant lock (found when
# rebasing this fix over the #69843 salvage which added the method).
pool.try_refresh_matching()
def _exhausted_billing_store(tmp_path, *, age_seconds: float):
"""An auth store with one deepseek entry benched for a billing failure."""
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"deepseek": [
{
"id": "cred-1",
"label": "api-key-1",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-test",
"last_status": "exhausted",
"last_status_at": time.time() - age_seconds,
"last_error_code": 402,
"last_error_reason": "invalid_request_error",
"last_error_message": "Insufficient Balance",
"failure_reason": "billing",
}
]
},
},
)
def _disk_entry(tmp_path) -> dict:
"""The deepseek entry as it actually reached disk."""
store = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = store["credential_pool"]["deepseek"]
assert len(entries) == 1, entries
return entries[0]
def test_reset_statuses_clears_a_cooldown_that_is_still_binding(tmp_path, monkeypatch):
"""An operator reset has to survive the disk-recency merge.
``write_credential_pool`` keeps a NEWER on-disk cooldown over the caller's
snapshot so one process cannot resurrect a key another has just benched.
``reset_statuses`` clears ``last_status_at`` to None, which that merge reads
as epoch 0 — older than any real timestamp — so the reset always lost and
the cooldown was copied straight back. ``hermes auth reset`` printed "Reset
status on 1 credentials" and changed nothing on disk.
The cooldown here is deliberately RECENT. Once a cooldown has expired the
merge bails out early, so the same assertions pass with or without the fix:
a test written against an expired cooldown proves nothing. Verified by
reverting the source change with the tests kept: this one and the
failure_reason test fail, and the guard test below keeps passing, which is
how it is known to pin pre-existing behaviour rather than the new flag.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_exhausted_billing_store(tmp_path, age_seconds=5)
from agent.credential_pool import load_pool
assert load_pool("deepseek").reset_statuses() == 1
entry = _disk_entry(tmp_path)
assert entry["last_status"] is None
assert entry["last_status_at"] is None
assert entry["last_error_code"] is None
# And a fresh load agrees, which is what the next process will see. The
# operational symptom of the bug was the CLI refusing the provider outright
# with "No usable credentials found", so availability is the property that
# matters here, not any single field.
assert load_pool("deepseek").has_available() is True
def test_reset_statuses_clears_the_classified_failure_reason(tmp_path, monkeypatch):
"""``failure_reason`` is part of the exhaustion state, so a reset clears it.
It lives in ``extra`` rather than as a dataclass field, so ``replace()``
could not reach it and it outlived every reset — leaving an entry with no
status and no error code but still classified ``billing``. ``hermes auth
list`` renders that leftover as though it were a current finding.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_exhausted_billing_store(tmp_path, age_seconds=5)
from agent.credential_pool import load_pool
assert load_pool("deepseek").reset_statuses() == 1
entry = _disk_entry(tmp_path)
assert entry.get("failure_reason") is None
def test_a_persist_without_declared_intent_still_cannot_erase_a_cooldown(
tmp_path, monkeypatch
):
"""The concurrency guard the fix threads through must still hold.
This is the property ``status_cleared_ids`` is scoped against: a writer that
has NOT declared a deliberate clear is presumed to be holding a stale
snapshot, and a binding on-disk cooldown outranks it. Without this test the
fix could have been "skip the merge always", which would let one process
resurrect a key another had just rate-limited — the exact lost update the
merge exists to prevent.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_exhausted_billing_store(tmp_path, age_seconds=5)
from hermes_cli.auth import write_credential_pool
# A stale snapshot: same id, status cleared, intent NOT declared.
write_credential_pool(
"deepseek",
[
{
"id": "cred-1",
"label": "api-key-1",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-test",
"last_status": None,
"last_status_at": None,
"last_error_code": None,
}
],
)
entry = _disk_entry(tmp_path)
assert entry["last_status"] == "exhausted"
assert entry["last_error_code"] == 402
def test_live_pool_flush_does_not_resurrect_a_cooldown_reset_by_another_process(tmp_path, monkeypatch):
"""A running session's next ordinary flush must not undo ``hermes auth reset`` (#89415).
The live pool still holds the entry as exhausted in memory; the CLI in another
process clears it on disk. Before the fix the cleared disk row had no status,
so the recency merge let the stale in-memory cooldown win and the reset was
silently reverted by the next rotation / refresh / sibling 429. The reset's
own ``status_cleared_at`` marker now outranks any older in-memory status, on
the save side (disk stays clear) and on the read side (the live pool lifts
its cooldown and serves the credential again).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_exhausted_billing_store(tmp_path, age_seconds=5)
from agent.credential_pool import load_pool
live = load_pool("deepseek") # process A: session already running
assert live.has_available() is False
assert load_pool("deepseek").reset_statuses() == 1 # process B: `hermes auth reset deepseek`
live._persist() # A's next ordinary flush
assert _disk_entry(tmp_path)["last_status"] is None
assert live.select() is not None # A honours the reset without a restart
assert _disk_entry(tmp_path)["last_status"] != "exhausted"
def test_an_exhaustion_newer_than_the_reset_still_binds(tmp_path, monkeypatch):
"""The reset marker is sticky, so it must only outrank OLDER statuses.
Reset first, then a fresh 402 on the same entry: the new cooldown postdates
the reset and has to survive both a flush and re-selection, or a single
reset would make the credential immune to benching for the rest of the run.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_exhausted_billing_store(tmp_path, age_seconds=5)
from agent.credential_pool import load_pool
assert load_pool("deepseek").reset_statuses() == 1
live = load_pool("deepseek")
assert live.select() is not None
live.mark_exhausted_and_rotate(status_code=402, api_key_hint="sk-test",
error_context={"message": "Insufficient Balance"})
live._persist()
assert _disk_entry(tmp_path)["last_status"] == "exhausted"
assert live.select() is None