Skip to content
Open
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
91 changes: 50 additions & 41 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,7 @@ def _refresh_entry_impl(
logger.debug(
"xAI OAuth refresh token is terminally invalid; clearing local token state"
)
cleared = False
try:
with _auth_store_lock():
auth_store = _load_auth_store()
Expand All @@ -1165,21 +1166,23 @@ def _refresh_entry_impl(
}
_save_provider_state(auth_store, "xai-oauth", state)
_save_auth_store(auth_store)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleared should be set immediately after _save_auth_store(auth_store) inside the isinstance(tokens, dict) branch. If tokens is malformed, that branch performs no save but this post-block assignment still authorizes pool eviction.

cleared = True
except Exception as clear_exc:
logger.debug(
logger.warning(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
if cleared:
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
return None
# For openai-codex: same race as xAI/nous — another Hermes process
# may have consumed the refresh token between our proactive sync
Expand Down Expand Up @@ -1212,6 +1215,7 @@ def _refresh_entry_impl(
logger.debug(
"Codex OAuth refresh token is terminally invalid; clearing local token state"
)
cleared = False
try:
with _auth_store_lock():
auth_store = _load_auth_store()
Expand All @@ -1235,21 +1239,23 @@ def _refresh_entry_impl(
}
_save_provider_state(auth_store, "openai-codex", state)
_save_auth_store(auth_store)
cleared = True
except Exception as clear_exc:
logger.debug(
logger.warning(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
if cleared:
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
return None
# For nous: another process may have consumed the refresh token
# between our proactive sync and the HTTP call. Re-sync from
Expand All @@ -1273,6 +1279,7 @@ def _refresh_entry_impl(
return updated
if auth_mod._is_terminal_nous_refresh_error(exc):
logger.debug("Nous refresh token is terminally invalid; clearing local token state")
cleared = False
try:
with _auth_store_lock():
auth_store = _load_auth_store()
Expand All @@ -1299,24 +1306,26 @@ def _refresh_entry_impl(
)
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
cleared = True
except Exception as clear_exc:
logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc)

singleton_sources = {
auth_mod.NOUS_DEVICE_CODE_SOURCE,
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
}
removed_ids = [
item.id for item in self._entries
if item.source in singleton_sources
]
self._entries = [
item for item in self._entries
if item.source not in singleton_sources
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
logger.warning("Failed to clear terminal Nous OAuth state: %s", clear_exc)

if cleared:
singleton_sources = {
auth_mod.NOUS_DEVICE_CODE_SOURCE,
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
}
removed_ids = [
item.id for item in self._entries
if item.source in singleton_sources
]
self._entries = [
item for item in self._entries
if item.source not in singleton_sources
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
return None
self._mark_exhausted(entry, None)
return None
Expand Down
157 changes: 157 additions & 0 deletions tests/agent/test_credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,69 @@ def _terminal_refresh_failure(*_args, **_kwargs):
assert refresh_calls["count"] == 1


def test_nous_pool_terminal_refresh_keeps_entries_when_auth_store_save_fails(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared"))
_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": "access-token",
"refresh_token": "refresh-token",
"expires_at": "2026-03-24T12:00:00+00:00",
"agent_key": "agent-key",
"agent_key_expires_at": "2026-03-24T13:30:00+00:00",
}
},
},
)

import agent.credential_pool as credential_pool_mod
from agent.credential_pool import load_pool
from hermes_cli import auth as auth_mod
from hermes_cli.auth import AuthError

def _terminal_refresh_failure(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)

pool = load_pool("nous")
selected = pool.select()
assert selected is not None
assert selected.source == "device_code"

monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure)

def _save_failure(*_args, **_kwargs):
raise OSError("disk full")

monkeypatch.setattr(credential_pool_mod, "_save_auth_store", _save_failure)

assert pool.try_refresh_current() is None

# Quarantine save failed: the entry must stay in the pool so that auth.json
# and the pool remain consistent (both still hold the revoked token).
assert [entry.source for entry in pool.entries()] == ["device_code"]

# auth.json tokens must be untouched.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
nous_state = auth_payload["providers"]["nous"]
assert nous_state.get("refresh_token") == "refresh-token"
assert nous_state.get("access_token") == "access-token"


def test_load_pool_removes_nous_device_code_when_singleton_quarantined(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
Expand Down Expand Up @@ -2873,6 +2936,53 @@ def _terminal_refresh_failure(*_args, **_kwargs):
assert refresh_calls["count"] == 1


def test_xai_oauth_terminal_refresh_keeps_entries_when_auth_store_save_fails(
tmp_path, monkeypatch
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.delenv("XAI_OAUTH_ACCESS_TOKEN", raising=False)

_write_auth_store(tmp_path, _xai_auth_store("old-access-token", "old-refresh-token"))

import agent.credential_pool as credential_pool_mod
from agent.credential_pool import load_pool
import hermes_cli.auth as auth_mod
from hermes_cli.auth import AuthError

pool = load_pool("xai-oauth")
selected = pool.select()
assert selected is not None
assert selected.source == "device_code"

def _terminal_refresh_failure(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="xai-oauth",
code="xai_refresh_failed",
relogin_required=True,
)

monkeypatch.setattr(auth_mod, "refresh_xai_oauth_pure", _terminal_refresh_failure)

def _save_failure(*_args, **_kwargs):
raise OSError("disk full")

monkeypatch.setattr(credential_pool_mod, "_save_auth_store", _save_failure)

assert pool.try_refresh_current() is None

# Quarantine save failed: the entry must stay in the pool so that auth.json
# and the pool remain consistent (both still hold the revoked token).
assert [entry.source for entry in pool.entries()] == ["device_code"]

# auth.json tokens must be untouched.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
tokens = auth_payload["providers"]["xai-oauth"].get("tokens", {})
assert tokens.get("access_token") == "old-access-token"
assert tokens.get("refresh_token") == "old-refresh-token"


def test_xai_oauth_nonterminal_refresh_does_not_quarantine(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("XAI_API_KEY", raising=False)
Expand Down Expand Up @@ -3014,6 +3124,53 @@ def _terminal_refresh_failure(*_args, **_kwargs):
assert refresh_calls["count"] == 1


def test_codex_oauth_terminal_refresh_keeps_entries_when_auth_store_save_fails(
tmp_path, monkeypatch
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("CODEX_OAUTH_ACCESS_TOKEN", raising=False)

_write_auth_store(tmp_path, _codex_auth_store("old-access-token", "old-refresh-token"))

import agent.credential_pool as credential_pool_mod
from agent.credential_pool import load_pool
import hermes_cli.auth as auth_mod
from hermes_cli.auth import AuthError

pool = load_pool("openai-codex")
selected = pool.select()
assert selected is not None
assert selected.source == "device_code"

def _terminal_refresh_failure(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="openai-codex",
code="codex_refresh_failed",
relogin_required=True,
)

monkeypatch.setattr(auth_mod, "refresh_codex_oauth_pure", _terminal_refresh_failure)

def _save_failure(*_args, **_kwargs):
raise OSError("disk full")

monkeypatch.setattr(credential_pool_mod, "_save_auth_store", _save_failure)

assert pool.try_refresh_current() is None

# Quarantine save failed: the entry must stay in the pool so that auth.json
# and the pool remain consistent (both still hold the revoked token).
assert [entry.source for entry in pool.entries()] == ["device_code"]

# auth.json tokens must be untouched.
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
tokens = auth_payload["providers"]["openai-codex"].get("tokens", {})
assert tokens.get("access_token") == "old-access-token"
assert tokens.get("refresh_token") == "old-refresh-token"


def test_codex_oauth_nonterminal_refresh_does_not_quarantine(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
Expand Down