NousResearch/hermes-agent

OAuth auth import and error handling fixes

Cluster 9283. Data as of

Nothing open on GitHub. Fix merged in #15104 on 24 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #15104. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

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.

  • #15104 fix(codex): consolidated OAuth error parsing + failed-status fallback routing + reauth UX

    merged to main

    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

    Show the diff
    • cli.py
      @@ -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,
      @@ -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
      @@ -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"}:
      @@ -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
      @@ -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
      @@ -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
      @@ -171,6 +171,8 @@
           "[redacted:email]": "satelerd",
           "[redacted:email]": "danklynn",
           "[redacted:email]": "MattMaximo",
      +    "[email protected]": "j3ffffff",
      +    "[email protected]": "A-FdL-Prog",
           "[redacted:email]": "nummanali",
           "[redacted:email]": "whitehatjr1001",
           "[email protected]": "0xNyk",
    • tests/hermes_cli/test_auth_codex_provider.py
      @@ -4,6 +4,7 @@
       import time
       import base64
       from pathlib import Path
      +from types import SimpleNamespace
       
       import pytest
       import yaml
      @@ -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,
       )
      @@ -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
      @@ -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},
      @@ -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 ──────────────────────────
       
       

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #9284 fix: auth add openai-codex should import from ~/.codex/auth.json before device code · superseded by another PR → #12360, #15104
Closed issues · 1
  • Closed#9283[Bug]: `hermes auth add openai-codex` skips ~/.codex/auth.json import, forces device code flow
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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

Merged PRs
#15104
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.