# NousResearch/hermes-agent · OAuth auth import and error handling fixes

## Open now

- Nothing open on GitHub.

These members address bugs and fixes in the \`hermes auth add openai-codex\` flow, specifically around importing credentials from ~/.codex/auth.json, OAuth error parsing, and fallback/reauth routing.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 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.67`: 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

- #9283: \[Bug\]: \`hermes auth add openai-codex\` skips ~/.codex/auth.json import, forces device code flow

## Prior & competing PRs

- **#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 ──────────────────────────
     
     
    ```
- **#9284: fix: auth add openai-codex should import from ~/.codex/auth.json before device code**. 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.
