# NousResearch/hermes-agent · Codex authentication handling and stale CLI auth sync in Hermes

## Open now

- Nothing open on GitHub.

These members all concern Codex auth in Hermes: one bug report about Codex CLI sync not recovering stale Hermes auth state for gateway/auxiliary flows, and three fix PRs addressing related areas (stale CLI auth sync recovery, OAuth error parsing with fallback routing and reauth UX, and Hermes managing its own auth rather than touching ~/.codex/auth.json). Whether the changes are complementary or conflicting is not determinable from the titles alone.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #15104. Verify it addresses your case.
- **Recommended:** read PR #15104 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #6651: \[Bug\]: Codex CLI sync does not recover stale Hermes auth state for gateway/auxiliary flows

## Prior & competing PRs

- **#12360: fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/credential_pool.py`, `hermes_cli/auth.py`, `tests/hermes_cli/test_auth_codex_provider.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -22,8 +22,6 @@
         _auth_store_lock,
         _codex_access_token_is_expiring,
         _decode_jwt_claims,
    -    _import_codex_cli_tokens,
    -    _write_codex_cli_tokens,
         _load_auth_store,
         _load_provider_state,
         _resolve_kimi_base_url,
    ```

    ```diff
    @@ -457,39 +455,6 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -
                 logger.debug("Failed to sync from credentials file: %s", exc)
             return entry
     
    -    def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential:
    -        """Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ.
    -
    -        OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
    -        When the Codex CLI (or another Hermes profile) refreshes its token,
    -        the pool entry's refresh_token becomes stale.  This method detects that
    -        by comparing against ~/.codex/auth.json and syncing the fresh pair.
    -        """
    -        if self.provider != "openai-codex":
    -            return entry
    -        try:
    -            cli_tokens = _import_codex_cli_tokens()
    -            if not cli_tokens:
    -                return entry
    -            cli_refresh = cli_tokens.get("refresh_token", "")
    -            cli_access = cli_tokens.get("access_token", "")
    -            if cli_refresh and cli_refresh != entry.refresh_token:
    -                logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id)
    -                updated = replace(
    -                    entry,
    -                    access_token=[redacted:secret-assignment]
    -                    refresh_token=[redacted:secret-assignment]
    -                    last_status=None,
    -                    last_status_at=None,
    -                    last_error_code=None,
    -                )
    -                self._replace_entry(entry, updated)
    -                self._persist()
    -                return updated
    -        except Exception as exc:
    -            logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
    -        return entry
    -
         def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:
             """Write refreshed pool entry tokens back to auth.json providers.
     
    ```

    ```diff
    @@ -585,13 +550,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
                         except Exception as wexc:
                             logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
                 elif self.provider == "openai-codex":
    -                # Proactively sync from ~/.codex/auth.json before refresh.
    -                # The Codex CLI (or another Hermes profile) may have already
    -                # consumed our refresh_token.  Syncing first avoids a
    -                # "refresh_token_reused" error when the CLI has a newer pair.
    -                synced = self._sync_codex_entry_from_cli(entry)
    -                if synced is not entry:
    -                    entry = synced
                     refreshed = auth_mod.refresh_codex_oauth_pure(
                         entry.access_token,
                         entry.refresh_token,
    ```

    ```diff
    @@ -677,45 +635,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
                         # Credentials file had a valid (non-expired) token — use it directly
                         logger.debug("Credentials file has valid token, using without refresh")
                         return synced
    -            # For openai-codex: the refresh_token may have been consumed by
    -            # the Codex CLI between our proactive sync and the refresh call.
    -            # Re-sync and retry once.
    -            if self.provider == "openai-codex":
    -                synced = self._sync_codex_entry_from_cli(entry)
    -                if synced.refresh_token != entry.refresh_token:
    -                    logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
    -                    try:
    -                        refreshed = auth_mod.refresh_codex_oauth_pure(
    -                            synced.access_token,
    -                            synced.refresh_token,
    -                        )
    -                        updated = replace(
    -                            synced,
    -                            access_token=[redacted:secret-assignment]"access_token"],
    -                            refresh_token=[redacted:secret-assignment]"refresh_token"],
    -                            last_refresh=refreshed.get("last_refresh"),
    -                            last_status=STATUS_OK,
    -                            last_status_at=None,
    -                            last_error_code=None,
    -                        )
    -                        self._replace_entry(synced, updated)
    -                        self._persist()
    -                        self._sync_device_code_entry_to_auth_store(updated)
    -                        try:
    -                            _write_codex_cli_tokens(
    -                                updated.access_token,
    -                                updated.refresh_token,
    -                                last_refresh=updated.last_refresh,
    -                            )
    -                        except Exception as wexc:
    -                            logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc)
    -                        return updated
    -                    except Exception as retry_exc:
    -                        logger.debug("Codex retry refresh also failed: %s", retry_exc)
    -                elif not self._entry_needs_refresh(synced):
    -                    logger.debug("Codex CLI has valid token, using without refresh")
    -                    self._sync_device_code_entry_to_auth_store(synced)
    -                    return synced
                 self._mark_exhausted(entry, None)
                 return None
     
    ```

    ```diff
    @@ -734,17 +653,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
             # _seed_from_singletons() on the next load_pool() sees fresh state
             # instead of re-seeding stale/consumed tokens.
             self._sync_device_code_entry_to_auth_store(updated)
    -        # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI
    -        # and VS Code don't hit "refresh_token_reused" on their next refresh.
    -        if self.provider == "openai-codex":
    -            try:
    -                _write_codex_cli_tokens(
    -                    updated.access_token,
    -                    updated.refresh_token,
    -                    last_refresh=updated.last_refresh,
    -                )
    -            except Exception as wexc:
    -                logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc)
             return updated
     
         def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
    ```

    ```diff
    @@ -790,16 +698,6 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal
                     if synced is not entry:
                         entry = synced
                         cleared_any = True
    -            # For openai-codex entries, sync from ~/.codex/auth.json before
    -            # any status/refresh checks.  This picks up tokens refreshed by
    -            # the Codex CLI or another Hermes profile.
    -            if (self.provider == "openai-codex"
    -                    and entry.last_status == STATUS_EXHAUSTED
    -                    and entry.refresh_token):
    -                synced = self._sync_codex_entry_from_cli(entry)
    -                if synced is not entry:
    -                    entry = synced
    -                    cleared_any = True
                 if entry.last_status == STATUS_EXHAUSTED:
                     exhausted_until = _exhausted_until(entry)
                     if exhausted_until is not None and now < exhausted_until:
    ```

    ```diff
    @@ -1218,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
         elif provider == "openai-codex":
             # Respect user suppression — `hermes auth remove openai-codex` marks
             # the device_code source as suppressed so it won't be re-seeded from
    -        # either the Hermes auth store or ~/.codex/auth.json.  Without this
    -        # gate the removal is instantly undone on the next load_pool() call.
    +        # the Hermes auth store.  Without this gate the removal is instantly
    +        # undone on the next load_pool() call.
             codex_suppressed = False
             try:
                 from hermes_cli.auth import is_source_suppressed
    ```

    ```diff
    @@ -1231,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
     
             state = _load_provider_state(auth_store, "openai-codex")
             tokens = state.get("tokens") if isinstance(state, dict) else None
    -        # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
    -        # store has no tokens.  This mirrors resolve_codex_runtime_credentials()
    -        # so that load_pool() and list_authenticated_providers() detect tokens
    -        # that only exist in the Codex CLI shared file.
    -        if not (isinstance(tokens, dict) and tokens.get("access_token")):
    -            try:
    -                from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
    -                cli_tokens = _import_codex_cli_tokens()
    -                if cli_tokens:
    -                    logger.info("Importing Codex CLI tokens into Hermes auth store.")
    -                    _save_codex_tokens(cli_tokens)
    -                    # Re-read state after import
    -                    auth_store = _load_auth_store()
    -                    state = _load_provider_state(auth_store, "openai-codex")
    -                    tokens = state.get("tokens") if isinstance(state, dict) else None
    -            except Exception as exc:
    -                logger.debug("Codex CLI token import failed: %s", exc)
    +        # Hermes owns its own Codex auth state — we do NOT auto-import from
    +        # ~/.codex/auth.json at pool-load time.  OAuth refresh tokens are
    +        # single-use, so sharing them with Codex CLI / VS Code causes
    +        # refresh_token_reused race failures.  Users who want to adopt
    +        # existing Codex CLI credentials get a one-time, explicit prompt
    +        # via `hermes auth openai-codex`.
             if isinstance(tokens, dict) and tokens.get("access_token"):
                 active_sources.add("device_code")
                 changed |= _upsert_entry(
    ```
  - `hermes_cli/auth.py`

    ```diff
    @@ -1434,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
         }
     
     
    -def _write_codex_cli_tokens(
    -    access_token: str,
    -    refresh_token: str,
    -    *,
    -    last_refresh: Optional[str] = None,
    -) -> None:
    -    """Write refreshed tokens back to ~/.codex/auth.json.
    -
    -    OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
    -    When Hermes refreshes a token it consumes the old refresh_token; if we
    -    don't write the new pair back, the Codex CLI (or VS Code extension) will
    -    fail with ``refresh_token_reused`` on its next refresh attempt.
    -
    -    This mirrors the Anthropic write-back to ~/.claude/.credentials.json
    -    via ``_write_claude_code_credentials()``.
    -    """
    -    codex_home = os.getenv("CODEX_HOME", "").strip()
    -    if not codex_home:
    -        codex_home = str(Path.home() / ".codex")
    -    auth_path = Path(codex_home).expanduser() / "auth.json"
    -    try:
    -        existing: Dict[str, Any] = {}
    -        if auth_path.is_file():
    -            existing = json.loads(auth_path.read_text(encoding="utf-8"))
    -        if not isinstance(existing, dict):
    -            existing = {}
    -
    -        tokens_dict = existing.get("tokens")
    -        if not isinstance(tokens_dict, dict):
    -            tokens_dict = {}
    -        tokens_dict["access_token"] = access_token
    -        tokens_dict["refresh_token"] = refresh_token
    -        existing["tokens"] = tokens_dict
    -        if last_refresh is not None:
    -            existing["last_refresh"] = last_refresh
    -
    -        auth_path.parent.mkdir(parents=True, exist_ok=True)
    -        auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
    -        auth_path.chmod(0o600)
    -    except (OSError, IOError) as exc:
    -        logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc)
    -
    -
     def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None:
         """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
         if last_refresh is None:
    ```

    ```diff
    @@ -1604,12 +1561,6 @@ def _refresh_codex_auth_tokens(
         updated_tokens["refresh_token"] = refreshed["refresh_token"]
     
         _save_codex_tokens(updated_tokens)
    -    # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.
    -    _write_codex_cli_tokens(
    -        refreshed["access_token"],
    -        refreshed["refresh_token"],
    -        last_refresh=refreshed.get("last_refresh"),
    -    )
         return updated_tokens
     
     
    ```

    ```diff
    @@ -1654,25 +1605,7 @@ def resolve_codex_runtime_credentials(
         refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
     ) -> Dict[str, Any]:
         """Resolve runtime credentials from Hermes's own Codex token store."""
    -    try:
    -        data = _read_codex_tokens()
    -    except AuthError as orig_err:
    -        # Only attempt migration when there are NO tokens stored at all
    -        # (code == "codex_auth_missing"), not when tokens exist but are invalid.
    -        if orig_err.code != "codex_auth_missing":
    -            raise
    -
    -        # Migration: user had Codex as active provider with old storage (~/.codex/).
    -        cli_tokens = _import_codex_cli_tokens()
    -        if cli_tokens:
    -            logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store")
    -            print("⚠️  Migrating Codex credentials to Hermes's own auth store.")
    -            print("   This avoids conflicts with Codex CLI and VS Code.")
    -            print("   Run `hermes auth` to create a fully independent session.\n")
    -            _save_codex_tokens(cli_tokens)
    -            data = _read_codex_tokens()
    -        else:
    -            raise
    +    data = _read_codex_tokens()
         tokens = [redacted:secret-assignment]"tokens"])
         access_token = [redacted:secret-assignment]"access_token", "") or "").strip()
         refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20"))
    ```
  - `tests/hermes_cli/test_auth_codex_provider.py`

    ```diff
    @@ -14,7 +14,6 @@
         PROVIDER_REGISTRY,
         _read_codex_tokens,
         _save_codex_tokens,
    -    _write_codex_cli_tokens,
         _import_codex_cli_tokens,
         get_codex_auth_status,
         get_provider_auth_state,
    ```

    ```diff
    @@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
         assert data["tokens"]["access_token"] == "hermes-at"
     
     
    -def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch):
    -    """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens."""
    -    codex_home = tmp_path / "codex-cli"
    -    monkeypatch.setenv("CODEX_HOME", str(codex_home))
    -
    -    _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z")
    -
    -    auth_path = codex_home / "auth.json"
    -    assert auth_path.exists()
    -    data = json.loads(auth_path.read_text())
    -    assert data["tokens"]["access_token"] == "new-access"
    -    assert data["tokens"]["refresh_token"] == "new-refresh"
    -    assert data["last_refresh"] == "2026-04-12T00:00:00Z"
    -    # Verify file permissions are restricted
    -    assert (auth_path.stat().st_mode & 0o777) == 0o600
    -
    -
    -def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch):
    -    """_write_codex_cli_tokens preserves extra fields in existing auth.json."""
    -    codex_home = tmp_path / "codex-cli"
    -    codex_home.mkdir(parents=True, exist_ok=True)
    -    monkeypatch.setenv("CODEX_HOME", str(codex_home))
    -
    -    existing = {
    -        "tokens": {
    -            "access_token": "[redacted:secret-assignment]",
    -            "refresh_token": "[redacted:secret-assignment]",
    -            "extra_field": "preserved",
    -        },
    -        "last_refresh": "2026-01-01T00:00:00Z",
    -        "custom_key": "keep_me",
    -    }
    -    (codex_home / "auth.json").write_text(json.dumps(existing))
    -
    -    _write_codex_cli_tokens("updated-access", "updated-refresh")
    -
    -    data = json.loads((codex_home / "auth.json").read_text())
    -    assert data["tokens"]["access_token"] == "updated-access"
    -    assert data["tokens"]["refresh_token"] == "updated-refresh"
    -    assert data["tokens"]["extra_field"] == "preserved"
    -    assert data["custom_key"] == "keep_me"
    -    # last_refresh not updated since we didn't pass it
    -    assert data["last_refresh"] == "2026-01-01T00:00:00Z"
    -
    -
    -def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch):
    -    """_write_codex_cli_tokens creates parent directories if missing."""
    -    codex_home = tmp_path / "does" / "not" / "exist"
    -    monkeypatch.setenv("CODEX_HOME", str(codex_home))
    -
    -    _write_codex_cli_tokens("at", "rt")
    -
    -    assert (codex_home / "auth.json").exists()
    -    data = json.loads((codex_home / "auth.json").read_text())
    -    assert data["tokens"]["access_token"] == "at"
    -
    -
    -def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch):
    -    """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json."""
    -    from hermes_cli.auth import _refresh_codex_auth_tokens
    -
    -    hermes_home = tmp_path / "hermes"
    -    codex_home = tmp_path / "codex-cli"
    -    hermes_home.mkdir(parents=True, exist_ok=True)
    -    codex_home.mkdir(parents=True, exist_ok=True)
    -    (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
    -    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    -    monkeypatch.setenv("CODEX_HOME", str(codex_home))
    -
    -    # Write initial CLI tokens
    -    (codex_home / "auth.json").write_text(json.dumps({
    -        "tokens": {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"},
    -    }))
    -
    -    # Mock the pure refresh to return new tokens
    -    monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: {
    -        "access_token": "[redacted:secret-assignment]",
    -        "refresh_token": "[redacted:secret-assignment]",
    -        "last_refresh": "2026-04-12T01:00:00Z",
    -    })
    -
    -    _refresh_codex_auth_tokens(
    -        {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"},
    -        timeout_seconds=10,
    -    )
    -
    -    # Verify CLI file was updated
    -    cli_data = json.loads((codex_home / "auth.json").read_text())
    -    assert cli_data["tokens"]["access_token"] == "refreshed-at"
    -    assert cli_data["tokens"]["refresh_token"] == "refreshed-rt"
    -
    -
     def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
         hermes_home = tmp_path / "hermes"
         _setup_hermes_auth(hermes_home)
    ```
- **#15104: fix(codex): consolidated OAuth error parsing + failed-status fallback routing + reauth UX**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cli.py`, `hermes_cli/auth.py`, `hermes_cli/main.py`, `run_agent.py`, `scripts/release.py`, `tests/hermes_cli/test_auth_codex_provider.py`, `tests/hermes_cli/test_codex_models.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -3083,6 +3083,8 @@ def _ensure_runtime_credentials(self) -> bool:
                 format_runtime_provider_error,
             )
     
    +        _primary_exc = None
    +        runtime = None
             try:
                 runtime = resolve_runtime_provider(
                     requested=self.requested_provider,
    ```

    ```diff
    @@ -3090,7 +3092,34 @@ def _ensure_runtime_credentials(self) -> bool:
                     explicit_base_url=self._explicit_base_url,
                 )
             except Exception as exc:
    -            message = format_runtime_provider_error(exc)
    +            _primary_exc = exc
    +
    +        # Primary provider auth failed — try fallback providers before giving up.
    +        if runtime is None and _primary_exc is not None:
    +            from hermes_cli.auth import AuthError
    +            if isinstance(_primary_exc, AuthError):
    +                _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
    +                for _fb in _fb_chain:
    +                    _fb_provider = (_fb.get("provider") or "").strip().lower()
    +                    _fb_model = (_fb.get("model") or "").strip()
    +                    if not _fb_provider or not _fb_model:
    +                        continue
    +                    try:
    +                        runtime = resolve_runtime_provider(requested=_fb_provider)
    +                        logger.warning(
    +                            "Primary provider auth failed (%s). Falling through to fallback: %s/%s",
    +                            _primary_exc, _fb_provider, _fb_model,
    +                        )
    +                        _cprint(f"⚠️  Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
    +                        self.requested_provider = _fb_provider
    +                        self.model = _fb_model
    +                        _primary_exc = None
    +                        break
    +                    except Exception:
    +                        continue
    +
    +        if runtime is None:
    +            message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
                 ChatConsole().print(f"[bold red]{message}[/]")
                 return False
     
    ```
  - `hermes_cli/auth.py`

    ```diff
    @@ -1547,12 +1547,21 @@ def refresh_codex_oauth_pure(
             try:
                 err = response.json()
                 if isinstance(err, dict):
    -                err_code = err.get("error")
    -                if isinstance(err_code, str) and err_code.strip():
    -                    code = err_code.strip()
    -                err_desc = err.get("error_description") or err.get("message")
    -                if isinstance(err_desc, str) and err_desc.strip():
    -                    message = f"Codex token refresh failed: {err_desc.strip()}"
    +                err_obj = err.get("error")
    +                # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}}
    +                if isinstance(err_obj, dict):
    +                    nested_code = err_obj.get("code") or err_obj.get("type")
    +                    if isinstance(nested_code, str) and nested_code.strip():
    +                        code = nested_code.strip()
    +                    nested_msg = err_obj.get("message")
    +                    if isinstance(nested_msg, str) and nested_msg.strip():
    +                        message = f"Codex token refresh failed: {nested_msg.strip()}"
    +                # OAuth spec shape: {"error": "code_str", "error_description": "..."}
    +                elif isinstance(err_obj, str) and err_obj.strip():
    +                    code = err_obj.strip()
    +                    err_desc = err.get("error_description") or err.get("message")
    +                    if isinstance(err_desc, str) and err_desc.strip():
    +                        message = f"Codex token refresh failed: {err_desc.strip()}"
             except Exception:
                 pass
             if code in {"invalid_grant", "invalid_token", "invalid_request"}:
    ```

    ```diff
    @@ -3088,52 +3097,61 @@ def login_command(args) -> None:
         raise SystemExit(0)
     
     
    -def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
    +def _login_openai_codex(
    +    args,
    +    pconfig: ProviderConfig,
    +    *,
    +    force_new_login: bool = False,
    +) -> None:
         """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json."""
     
    +    del args, pconfig  # kept for parity with other provider login helpers
    +
         # Check for existing Hermes-owned credentials
    -    try:
    -        existing = resolve_codex_runtime_credentials()
    -        # Verify the resolved token is actually usable (not expired).
    -        # resolve_codex_runtime_credentials attempts refresh, so if we get
    -        # here the token should be valid — but double-check before telling
    -        # the user "Login successful!".
    -        _resolved_key = existing.get("api_key", "")
    -        if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):
    -            print("Existing Codex credentials found in Hermes auth store.")
    +    if not force_new_login:
    +        try:
    +            existing = resolve_codex_runtime_credentials()
    +            # Verify the resolved token is actually usable (not expired).
    +            # resolve_codex_runtime_credentials attempts refresh, so if we get
    +            # here the token should be valid — but double-check before telling
    +            # the user "Login successful!".
    +            _resolved_key = existing.get("api_key", "")
    +            if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):
    +                print("Existing Codex credentials found in Hermes auth store.")
    +                try:
    +                    reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
    +                except (EOFError, KeyboardInterrupt):
    +                    reuse = "y"
    +                if reuse in ("", "y", "yes"):
    +                    config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL))
    +                    print()
    +                    print("Login successful!")
    +                    print(f"  Config updated: {config_path} (model.provider=openai-codex)")
    +                    return
    +            else:
    +                print("Existing Codex credentials are expired. Starting fresh login...")
    +        except AuthError:
    +            pass
    +
    +    # Check for existing Codex CLI tokens we can import
    +    if not force_new_login:
    +        cli_tokens = _import_codex_cli_tokens()
    +        if cli_tokens:
    +            print("Found existing Codex CLI credentials at ~/.codex/auth.json")
    +            print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.")
                 try:
    -                reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
    +                do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower()
                 except (EOFError, KeyboardInterrupt):
    -                reuse = "y"
    -            if reuse in ("", "y", "yes"):
    -                config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL))
    +                do_import = "n"
    +            if do_import in ("y", "yes"):
    +                _save_codex_tokens(cli_tokens)
    +                base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL
    +                config_path = _update_config_for_provider("openai-codex", base_url)
                     print()
    -                print("Login successful!")
    +                print("Credentials imported. Note: if Codex CLI refreshes its token,")
    +                print("Hermes will keep working independently with its own session.")
                     print(f"  Config updated: {config_path} (model.provider=openai-codex)")
                     return
    -        else:
    -            print("Existing Codex credentials are expired. Starting fresh login...")
    -    except AuthError:
    -        pass
    -
    -    # Check for existing Codex CLI tokens we can import
    -    cli_tokens = _import_codex_cli_tokens()
    -    if cli_tokens:
    -        print("Found existing Codex CLI credentials at ~/.codex/auth.json")
    -        print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.")
    -        try:
    -            do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower()
    -        except (EOFError, KeyboardInterrupt):
    -            do_import = "n"
    -        if do_import in ("y", "yes"):
    -            _save_codex_tokens(cli_tokens)
    -            base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL
    -            config_path = _update_config_for_provider("openai-codex", base_url)
    -            print()
    -            print("Credentials imported. Note: if Codex CLI refreshes its token,")
    -            print("Hermes will keep working independently with its own session.")
    -            print(f"  Config updated: {config_path} (model.provider=openai-codex)")
    -            return
     
         # Run a fresh device code flow — Hermes gets its own OAuth session
         print()
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -2328,7 +2328,41 @@ def _model_flow_openai_codex(config, current_model=""):
         from hermes_cli.codex_models import get_codex_model_ids
     
         status = get_codex_auth_status()
    -    if not status.get("logged_in"):
    +    if status.get("logged_in"):
    +        print("  OpenAI Codex credentials: ✓")
    +        print()
    +        print("    1. Use existing credentials")
    +        print("    2. Reauthenticate (new OAuth login)")
    +        print("    3. Cancel")
    +        print()
    +        try:
    +            choice = input("  Choice [1/2/3]: ").strip()
    +        except (KeyboardInterrupt, EOFError):
    +            choice = "1"
    +
    +        if choice == "2":
    +            print("Starting a fresh OpenAI Codex login...")
    +            print()
    +            try:
    +                mock_args = argparse.Namespace()
    +                _login_openai_codex(
    +                    mock_args,
    +                    PROVIDER_REGISTRY["openai-codex"],
    +                    force_new_login=True,
    +                )
    +            except SystemExit:
    +                print("Login cancelled or failed.")
    +                return
    +            except Exception as exc:
    +                print(f"Login failed: {exc}")
    +                return
    +            status = get_codex_auth_status()
    +            if not status.get("logged_in"):
    +                print("Login failed.")
    +                return
    +        elif choice == "3":
    +            return
    +    else:
             print("Not logged into OpenAI Codex. Starting login...")
             print()
             try:
    ```
  - `run_agent.py`

    ```diff
    @@ -9532,28 +9532,47 @@ def _stop_spinner():
                                     response_invalid = True
                                     error_details.append("response is None")
                                 else:
    -                                # output_text fallback: stream backfill may have failed
    -                                # but normalize can still recover from output_text
    -                                _out_text = getattr(response, "output_text", None)
    -                                _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else ""
    -                                if _out_text_stripped:
    -                                    logger.debug(
    -                                        "Codex response.output is empty but output_text is present "
    -                                        "(%d chars); deferring to normalization.",
    -                                        len(_out_text_stripped),
    +                                # Provider returned a terminal failure (e.g. quota exhaustion).
    +                                # Treat as invalid so the fallback chain is triggered instead of
    +                                # letting the error bubble up outside the retry/fallback loop.
    +                                _codex_resp_status = str(getattr(response, "status", "") or "").strip().lower()
    +                                if _codex_resp_status in {"failed", "cancelled"}:
    +                                    _codex_error_obj = getattr(response, "error", None)
    +                                    _codex_error_msg = (
    +                                        _codex_error_obj.get("message") if isinstance(_codex_error_obj, dict)
    +                                        else str(_codex_error_obj) if _codex_error_obj
    +                                        else f"Responses API returned status '{_codex_resp_status}'"
                                         )
    -                                else:
    -                                    _resp_status = getattr(response, "status", None)
    -                                    _resp_incomplete = getattr(response, "incomplete_details", None)
    -                                    logger.warning(
    -                                        "Codex response.output is empty after stream backfill "
    -                                        "(status=%s, incomplete_details=%s, model=%s). %s",
    -                                        _resp_status, _resp_incomplete,
    -                                        getattr(response, "model", None),
    -                                        f"api_mode={self.api_mode} provider={self.provider}",
    +                                    logging.warning(
    +                                        "Codex response status='%s' (error=%s). Routing to fallback. %s",
    +                                        _codex_resp_status, _codex_error_msg,
    +                                        self._client_log_context(),
                                         )
                                         response_invalid = True
    -                                    error_details.append("response.output is empty")
    +                                    error_details.append(f"response.status={_codex_resp_status}: {_codex_error_msg}")
    +                                else:
    +                                    # output_text fallback: stream backfill may have failed
    +                                    # but normalize can still recover from output_text
    +                                    _out_text = getattr(response, "output_text", None)
    +                                    _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else ""
    +                                    if _out_text_stripped:
    +                                        logger.debug(
    +                                            "Codex response.output is empty but output_text is present "
    +                                            "(%d chars); deferring to normalization.",
    +                                            len(_out_text_stripped),
    +                                        )
    +                                    else:
    +                                        _resp_status = getattr(response, "status", None)
    +                                        _resp_incomplete = getattr(response, "incomplete_details", None)
    +                                        logger.warning(
    +                                            "Codex response.output is empty after stream backfill "
    +                                            "(status=%s, incomplete_details=%s, model=%s). %s",
    +                                            _resp_status, _resp_incomplete,
    +                                            getattr(response, "model", None),
    +                                            f"api_mode={self.api_mode} provider={self.provider}",
    +                                        )
    +                                        response_invalid = True
    +                                        error_details.append("response.output is empty")
                         elif self.api_mode == "anthropic_messages":
                             _tv = self._get_transport()
                             if not _tv.validate_response(response):
    ```
  - `scripts/release.py`

    ```diff
    @@ -171,6 +171,8 @@
         "[redacted:email]": "satelerd",
         "[redacted:email]": "danklynn",
         "[redacted:email]": "MattMaximo",
    +    "149063006+j3ffffff@users.noreply.github.com": "j3ffffff",
    +    "A-FdL-Prog@users.noreply.github.com": "A-FdL-Prog",
         "[redacted:email]": "nummanali",
         "[redacted:email]": "whitehatjr1001",
         "0xNyk@users.noreply.github.com": "0xNyk",
    ```
  - `tests/hermes_cli/test_auth_codex_provider.py`

    ```diff
    @@ -4,6 +4,7 @@
     import time
     import base64
     from pathlib import Path
    +from types import SimpleNamespace
     
     import pytest
     import yaml
    ```

    ```diff
    @@ -15,8 +16,10 @@
         _read_codex_tokens,
         _save_codex_tokens,
         _import_codex_cli_tokens,
    +    _login_openai_codex,
         get_codex_auth_status,
         get_provider_auth_state,
    +    refresh_codex_oauth_pure,
         resolve_codex_runtime_credentials,
         resolve_provider,
     )
    ```

    ```diff
    @@ -190,3 +193,161 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
         assert creds["source"] == "hermes-auth-store"
         assert creds["provider"] == "openai-codex"
         assert creds["base_url"] == DEFAULT_CODEX_BASE_URL
    +
    +
    +class _StubHTTPResponse:
    +    def __init__(self, status_code: int, payload):
    +        self.status_code = status_code
    +        self._payload = payload
    +        self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload)
    +
    +    def json(self):
    +        if isinstance(self._payload, Exception):
    +            raise self._payload
    +        return self._payload
    +
    +
    +class _StubHTTPClient:
    +    def __init__(self, response):
    +        self._response = response
    +
    +    def __enter__(self):
    +        return self
    +
    +    def __exit__(self, *args):
    +        return False
    +
    +    def post(self, *args, **kwargs):
    +        return self._response
    +
    +
    +def _patch_httpx(monkeypatch, response):
    +    def _factory(*args, **kwargs):
    +        return _StubHTTPClient(response)
    +
    +    monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory)
    +
    +
    +def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch):
    +    """OpenAI returns {"error": {"code": "refresh_token_reused", "message": "..."}}
    +    — parser must surface relogin_required and the dedicated message.
    +    """
    +    response = _StubHTTPResponse(
    +        401,
    +        {
    +            "error": {
    +                "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.",
    +                "type": "invalid_request_error",
    +                "param": None,
    +                "code": "refresh_token_reused",
    +            }
    +        },
    +    )
    +    _patch_httpx(monkeypatch, response)
    +
    +    with pytest.raises(AuthError) as exc_info:
    +        refresh_codex_oauth_pure("a-tok", "r-tok")
    +
    +    err = exc_info.value
    +    assert err.code == "refresh_token_reused"
    +    assert err.relogin_required is True
    +    # The existing dedicated branch should override the message with actionable guidance.
    +    assert "already consumed by another client" in str(err)
    +
    +
    +def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch):
    +    """Nested error with arbitrary code still surfaces code + message."""
    +    response = _StubHTTPResponse(
    +        400,
    +        {
    +            "error": {
    +                "message": "Invalid client credentials.",
    +                "type": "invalid_request_error",
    +                "code": "invalid_client",
    +            }
    +        },
    +    )
    +    _patch_httpx(monkeypatch, response)
    +
    +    with pytest.raises(AuthError) as exc_info:
    +        refresh_codex_oauth_pure("a-tok", "r-tok")
    +
    +    err = exc_info.value
    +    assert err.code == "invalid_client"
    +    assert "Invalid client credentials." in str(err)
    +
    +
    +def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch):
    +    """Fallback path: OAuth spec-shape {"error": "invalid_grant", "error_description": "..."}
    +    must still map to relogin_required=True via the existing code set.
    +    """
    +    response = _StubHTTPResponse(
    +        400,
    +        {
    +            "error": "invalid_grant",
    +            "error_description": "Refresh token is expired or revoked.",
    +        },
    +    )
    +    _patch_httpx(monkeypatch, response)
    +
    +    with pytest.raises(AuthError) as exc_info:
    +        refresh_codex_oauth_pure("a-tok", "r-tok")
    +
    +    err = exc_info.value
    +    assert err.code == "invalid_grant"
    +    assert err.relogin_required is True
    +    assert "Refresh token is expired or revoked." in str(err)
    +
    +
    +def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch):
    +    """No JSON body → generic 'with status 401' message; 401 always forces relogin."""
    +    response = _StubHTTPResponse(401, ValueError("not json"))
    +    _patch_httpx(monkeypatch, response)
    +
    +    with pytest.raises(AuthError) as exc_info:
    +        refresh_codex_oauth_pure("a-tok", "r-tok")
    +
    +    err = exc_info.value
    +    assert err.code == "codex_refresh_failed"
    +    # 401/403 from the token endpoint always means the refresh token is
    +    # invalid/expired — force relogin even without a parseable error body.
    +    assert err.relogin_required is True
    +    assert "status 401" in str(err)
    +
    +
    +def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch):
    +    called = {"device_login": 0}
    +
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.resolve_codex_runtime_credentials",
    +        lambda: {"base_url": DEFAULT_CODEX_BASE_URL},
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth._import_codex_cli_tokens",
    +        lambda: {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"},
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth._codex_device_code_login",
    +        lambda: {
    +            "tokens": {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"},
    +            "last_refresh": "2026-04-01T00:00:00Z",
    +            "base_url": DEFAULT_CODEX_BASE_URL,
    +        },
    +    )
    +
    +    def _fake_save(tokens, last_refresh=None):
    +        called["device_login"] += 1
    +        called["tokens"] = dict(tokens)
    +        called["last_refresh"] = last_refresh
    +
    +    monkeypatch.setattr("hermes_cli.auth._save_codex_tokens", _fake_save)
    +    monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda *args, **kwargs: "/tmp/config.yaml")
    +    monkeypatch.setattr(
    +        "builtins.input",
    +        lambda prompt="": (_ for _ in ()).throw(AssertionError("force_new_login should not prompt for reuse/import")),
    +    )
    +
    +    _login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY["openai-codex"], force_new_login=True)
    +
    +    assert called["device_login"] == 1
    +    assert called["tokens"]["access_token"] == "fresh-at"
    ```
  - `tests/hermes_cli/test_codex_models.py`

    ```diff
    @@ -72,7 +72,9 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
         from hermes_cli.main import _model_flow_openai_codex
     
         captured = {}
    +    choices = iter(["1"])
     
    +    monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
         monkeypatch.setattr(
             "hermes_cli.auth.get_codex_auth_status",
             lambda: {"logged_in": True},
    ```

    ```diff
    @@ -107,6 +109,83 @@ def _fake_prompt_model_selection(model_ids, current_model=""):
         assert captured["current_model"] == "openai/gpt-5.4"
     
     
    +def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys):
    +    from hermes_cli.main import _model_flow_openai_codex
    +
    +    captured = {"login_calls": 0}
    +    choices = iter(["2"])
    +
    +    monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.get_codex_auth_status",
    +        lambda: {"logged_in": True, "source": "hermes-auth-store"},
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.resolve_codex_runtime_credentials",
    +        lambda *args, **kwargs: {"api_key": "[redacted:secret-assignment]"},
    +    )
    +
    +    def _fake_login(*args, force_new_login=False, **kwargs):
    +        captured["login_calls"] += 1
    +        captured["force_new_login"] = force_new_login
    +
    +    monkeypatch.setattr("hermes_cli.auth._login_openai_codex", _fake_login)
    +    monkeypatch.setattr(
    +        "hermes_cli.codex_models.get_codex_model_ids",
    +        lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth._prompt_model_selection",
    +        lambda model_ids, current_model="": None,
    +    )
    +
    +    _model_flow_openai_codex({}, current_model="gpt-5.4")
    +
    +    out = capsys.readouterr().out
    +    assert "Use existing credentials" in out
    +    assert "Reauthenticate (new OAuth login)" in out
    +    assert captured["login_calls"] == 1
    +    assert captured["force_new_login"] is True
    +
    +
    +def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
    +    from hermes_cli.main import _model_flow_openai_codex
    +
    +    choices = iter(["1"])
    +    captured = {}
    +
    +    monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.get_codex_auth_status",
    +        lambda: {"logged_in": True, "source": "hermes-auth-store"},
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.resolve_codex_runtime_credentials",
    +        lambda *args, **kwargs: {"api_key": "[redacted:secret-assignment]"},
    +    )
    +
    +    def _fake_get_codex_model_ids(access_token=[redacted:secret-assignment]
    +        captured["access_token"] = access_token
    +        return ["gpt-5.4"]
    +
    +    monkeypatch.setattr(
    +        "hermes_cli.codex_models.get_codex_model_ids",
    +        _fake_get_codex_model_ids,
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth._prompt_model_selection",
    +        lambda model_ids, current_model="": None,
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.auth._login_openai_codex",
    +        lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")),
    +    )
    +
    +    _model_flow_openai_codex({}, current_model="gpt-5.4")
    +
    +    assert captured["access_token"] == "existing-codex-token"
    +
    +
     # ── Tests for _normalize_model_for_provider ──────────────────────────
     
     
    ```
- **#6652: fix(codex): recover stale CLI auth sync state**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12360`, `#15104`

## Fix status

- Merged to main in PR #15104 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-24`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
