Skip to content
Closed
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
67 changes: 38 additions & 29 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
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 @@ -1035,17 +1036,19 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
}
_save_provider_state(auth_store, "xai-oauth", state)
_save_auth_store(auth_store)
cleared = True
except Exception as clear_exc:
logger.debug(
logger.warning(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
)
self._entries = [
item for item in self._entries
if item.source != "loopback_pkce"
]
if self._current_id == entry.id:
self._current_id = None
self._persist()
if cleared:
self._entries = [
item for item in self._entries
if item.source != "loopback_pkce"
]
if self._current_id == entry.id:
self._current_id = None
self._persist()
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 @@ -1078,6 +1081,7 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
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 @@ -1101,17 +1105,19 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
}
_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
)
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()
if cleared:
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()
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 @@ -1135,6 +1141,7 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
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 @@ -1161,20 +1168,22 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
)
_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}",
}
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()
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}",
}
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()
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 @@ -1393,6 +1393,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 @@ -2826,6 +2889,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 == "loopback_pkce"

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()] == ["loopback_pkce"]

# 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 @@ -2967,6 +3077,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