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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #33744 fix(auth): sync manual:device_code Codex pool entries on re-auth

    merged to main

    files: hermes_cli/auth.py, tests/hermes_cli/test_auth_codex_provider.py

    Show 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
  • Closed#33538openai-codex OAuth reauthorization can leave stale credential; auth add workaround required
  • Closed#42712[Bug]: hermes auth add openai-codex does not go to Oauth
  • Closed#43461[Bug]: OpenAI Codex usage completely Broken/Being Treated as Custom API
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.00 means 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 Lab

At a glance

Subsystem
agent
Merged PRs
#33744
Closed issues
3
Attempts
1 PR closed without merging
Fix link checked
28 May 2026

For your agent

The same facts, typed.