Skip to content
Merged
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
45 changes: 37 additions & 8 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3312,16 +3312,38 @@ def _sync_codex_pool_entries(
tokens: Dict[str, str],
last_refresh: Optional[str],
) -> None:
"""Mirror a fresh Codex re-auth into the credential_pool singleton entries.
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.

The runtime selects credentials from ``credential_pool.openai-codex``, not
from ``providers.openai-codex.tokens``. A re-auth invalidates the prior
OAuth pair server-side, but the pool's ``device_code`` entry keeps holding
the now-consumed refresh token plus any stale error markers — so the next
request spends a dead token and gets a 401 ``token_invalidated``. Update
the singleton-seeded entries in lockstep with the provider tokens and clear
the error state so the fresh credentials take effect immediately. Manual
(``manual:*``) entries are independent credentials and are left untouched.
OAuth pair server-side, but pool entries keep holding the now-consumed
refresh token plus any stale error markers — so the next request spends a
dead token and gets a 401 ``token_invalidated``.

What gets refreshed:

* ``device_code`` — the singleton-seeded entry written by the device-code
OAuth flow when the user logged in via ``hermes setup`` / the model
picker. Always synced with the fresh tokens.
* ``manual:device_code`` — entries created by ``hermes auth add openai-codex``
that use the same device-code OAuth mechanism. An interactive re-auth
proves the user owns the ChatGPT account, so it is safe (and expected)
to refresh these entries too. Without this, a user who once ran the
``hermes auth add`` workaround for #33000 would silently leave that
manual entry stale on every subsequent re-auth, recreating the issue
reported in #33538.

What does NOT get refreshed:

* ``manual:api_key`` and any other non-device-code manual sources — those
are independent credentials (an explicit API key, a different ChatGPT
account, etc.) and must not be overwritten by a single re-auth.

Error markers (``last_status``, ``last_error_*``) are also cleared on
every device-code-backed entry — even those whose tokens we did not
rewrite — so that an interactive re-auth gives every relevant pool entry
a fresh selection chance instead of leaving them marked unhealthy from a
pre-re-auth 401.
"""
access_token = tokens.get("access_token")
if not access_token:
Expand All @@ -3333,8 +3355,15 @@ def _sync_codex_pool_entries(
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return
# Sources whose tokens should be rewritten by a fresh Codex device-code
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
# excluded — they represent independent credentials.
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
for entry in entries:
if not isinstance(entry, dict) or entry.get("source") != "device_code":
if not isinstance(entry, dict):
continue
source = entry.get("source")
if source not in REFRESHABLE_SOURCES:
continue
entry["access_token"] = access_token
if refresh_token:
Expand Down
82 changes: 82 additions & 0 deletions tests/hermes_cli/test_auth_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,88 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at"


def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):
"""Re-auth must also refresh ``manual:device_code`` pool entries.

Regression for #33538: a user who hit #33000 before the #33164 fix landed
would have run ``hermes auth add openai-codex`` as a workaround, leaving
a pool entry with ``source="manual:device_code"``. On every subsequent
re-auth via setup/model picker, the singleton-seeded ``device_code`` entry
got refreshed but the ``manual:device_code`` entry stayed stale, recreating
the same 401 token_invalidated symptom that #33164 was supposed to fix.

An interactive Codex device-code re-auth proves the user owns the ChatGPT
account, so it is safe to refresh every device-code-backed entry in the
pool — but NOT independent ``manual:api_key`` entries (separate accounts /
explicit API keys).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "seeded",
"source": "device_code",
"auth_type": "oauth",
"access_token": "old-at",
"refresh_token": "old-rt",
},
{
"id": "auth-add",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "stale-manual-at",
"refresh_token": "stale-manual-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
{
"id": "api-key",
"source": "manual:api_key",
"auth_type": "api_key",
"access_token": "user-api-key",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))

_save_codex_tokens({"access_token": "fresh-at", "refresh_token": "fresh-rt"},
last_refresh="2026-05-28T00:00:00Z")

auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]

# Singleton-seeded device_code entry: refreshed and error markers cleared.
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "fresh-at"
assert seeded["refresh_token"] == "fresh-rt"

# manual:device_code entry: ALSO refreshed (the new behavior).
manual_dc = next(e for e in pool if e["source"] == "manual:device_code")
assert manual_dc["access_token"] == "fresh-at"
assert manual_dc["refresh_token"] == "fresh-rt"
assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z"
assert manual_dc["last_status"] is None
assert manual_dc["last_error_code"] is None
assert manual_dc["last_error_reason"] is None

# manual:api_key entry: untouched — independent credential.
api_key = next(e for e in pool if e["source"] == "manual:api_key")
assert api_key["access_token"] == "user-api-key"
assert "refresh_token" not in api_key or api_key.get("refresh_token") is None


def test_import_codex_cli_tokens(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)
Expand Down
Loading