Codex OAuth credential pool sync and lifecycle fixes
NousResearch/hermes-agent · cluster 33538 · agent
Data as of
Nothing open on GitHub. Fix merged in #33744. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #33744. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #33744. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address stale credentials, pool key mismatches, auth.json synchronization, and race conditions in the Codex OAuth authentication flow. Several entries also touch re-authorization paths and persistence alignment for device-code and manual auth methods.
Open issues0
Open PRs0
Fixes on record
-
#33744 fix(auth): sync manual:device_code Codex pool entries on re-auth
files:
hermes_cli/auth.py,tests/hermes_cli/test_auth_codex_provider.pyShow the diff
-
hermes_cli/auth.py@@ -3312,16 +3312,38 @@ def _sync_codex_pool_entries( tokens: [redacted:secret-assignment] 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 = [redacted:secret-assignment]"access_token") if not access_token: @@ -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: -
tests/hermes_cli/test_auth_codex_provider.py@@ -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 = [redacted:secret-assignment] 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)
-
1 PR closed without merging
From the close comments: closed without comment 1
- #36932 config-driven MOA; Credential pool reload on /model switch, Incremental token count for sessions · closed without comment
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.27: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #33744
- Closed issues
- 3
- Attempts
- 1 PR closed without merging
- Fix link checked
- 28 May 2026