{"repo":"NousResearch/hermes-agent","public_id":"9283","display_label":"OAuth auth import and error handling fixes","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15104,"merged_at":"2026-04-24T11:53:33+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-24T11:53:33+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6651,"pr_number":15104,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T11:53:33+00:00"},{"issue_number":9283,"pr_number":15104,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T11:53:33+00:00"}],"members":[{"kind":"issue","number":9283,"title":"[Bug]: `hermes auth add openai-codex` skips ~/.codex/auth.json import, forces device code flow","url":null,"state":"closed"},{"kind":"pr","number":9284,"title":"fix: auth add openai-codex should import from ~/.codex/auth.json before device code","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#15104","number":15104}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":15104,"title":"fix(codex): consolidated OAuth error parsing + failed-status fallback routing + reauth UX","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"cli.py","hunks":["@@ -3083,6 +3083,8 @@ def _ensure_runtime_credentials(self) -> bool:\n             format_runtime_provider_error,\n         )\n \n+        _primary_exc = None\n+        runtime = None\n         try:\n             runtime = resolve_runtime_provider(\n                 requested=self.requested_provider,","@@ -3090,7 +3092,34 @@ def _ensure_runtime_credentials(self) -> bool:\n                 explicit_base_url=self._explicit_base_url,\n             )\n         except Exception as exc:\n-            message = format_runtime_provider_error(exc)\n+            _primary_exc = exc\n+\n+        # Primary provider auth failed — try fallback providers before giving up.\n+        if runtime is None and _primary_exc is not None:\n+            from hermes_cli.auth import AuthError\n+            if isinstance(_primary_exc, AuthError):\n+                _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []\n+                for _fb in _fb_chain:\n+                    _fb_provider = (_fb.get(\"provider\") or \"\").strip().lower()\n+                    _fb_model = (_fb.get(\"model\") or \"\").strip()\n+                    if not _fb_provider or not _fb_model:\n+                        continue\n+                    try:\n+                        runtime = resolve_runtime_provider(requested=_fb_provider)\n+                        logger.warning(\n+                            \"Primary provider auth failed (%s). Falling through to fallback: %s/%s\",\n+                            _primary_exc, _fb_provider, _fb_model,\n+                        )\n+                        _cprint(f\"⚠️  Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}\")\n+                        self.requested_provider = _fb_provider\n+                        self.model = _fb_model\n+                        _primary_exc = None\n+                        break\n+                    except Exception:\n+                        continue\n+\n+        if runtime is None:\n+            message = format_runtime_provider_error(_primary_exc) if _primary_exc else \"Provider resolution failed.\"\n             ChatConsole().print(f\"[bold red]{message}[/]\")\n             return False\n "]},{"file":"hermes_cli/auth.py","hunks":["@@ -1547,12 +1547,21 @@ def refresh_codex_oauth_pure(\n         try:\n             err = response.json()\n             if isinstance(err, dict):\n-                err_code = err.get(\"error\")\n-                if isinstance(err_code, str) and err_code.strip():\n-                    code = err_code.strip()\n-                err_desc = err.get(\"error_description\") or err.get(\"message\")\n-                if isinstance(err_desc, str) and err_desc.strip():\n-                    message = f\"Codex token refresh failed: {err_desc.strip()}\"\n+                err_obj = err.get(\"error\")\n+                # OpenAI shape: {\"error\": {\"code\": \"...\", \"message\": \"...\", \"type\": \"...\"}}\n+                if isinstance(err_obj, dict):\n+                    nested_code = err_obj.get(\"code\") or err_obj.get(\"type\")\n+                    if isinstance(nested_code, str) and nested_code.strip():\n+                        code = nested_code.strip()\n+                    nested_msg = err_obj.get(\"message\")\n+                    if isinstance(nested_msg, str) and nested_msg.strip():\n+                        message = f\"Codex token refresh failed: {nested_msg.strip()}\"\n+                # OAuth spec shape: {\"error\": \"code_str\", \"error_description\": \"...\"}\n+                elif isinstance(err_obj, str) and err_obj.strip():\n+                    code = err_obj.strip()\n+                    err_desc = err.get(\"error_description\") or err.get(\"message\")\n+                    if isinstance(err_desc, str) and err_desc.strip():\n+                        message = f\"Codex token refresh failed: {err_desc.strip()}\"\n         except Exception:\n             pass\n         if code in {\"invalid_grant\", \"invalid_token\", \"invalid_request\"}:","@@ -3088,52 +3097,61 @@ def login_command(args) -> None:\n     raise SystemExit(0)\n \n \n-def _login_openai_codex(args, pconfig: ProviderConfig) -> None:\n+def _login_openai_codex(\n+    args,\n+    pconfig: ProviderConfig,\n+    *,\n+    force_new_login: bool = False,\n+) -> None:\n     \"\"\"OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json.\"\"\"\n \n+    del args, pconfig  # kept for parity with other provider login helpers\n+\n     # Check for existing Hermes-owned credentials\n-    try:\n-        existing = resolve_codex_runtime_credentials()\n-        # Verify the resolved token is actually usable (not expired).\n-        # resolve_codex_runtime_credentials attempts refresh, so if we get\n-        # here the token should be valid — but double-check before telling\n-        # the user \"Login successful!\".\n-        _resolved_key = existing.get(\"api_key\", \"\")\n-        if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):\n-            print(\"Existing Codex credentials found in Hermes auth store.\")\n+    if not force_new_login:\n+        try:\n+            existing = resolve_codex_runtime_credentials()\n+            # Verify the resolved token is actually usable (not expired).\n+            # resolve_codex_runtime_credentials attempts refresh, so if we get\n+            # here the token should be valid — but double-check before telling\n+            # the user \"Login successful!\".\n+            _resolved_key = existing.get(\"api_key\", \"\")\n+            if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):\n+                print(\"Existing Codex credentials found in Hermes auth store.\")\n+                try:\n+                    reuse = input(\"Use existing credentials? [Y/n]: \").strip().lower()\n+                except (EOFError, KeyboardInterrupt):\n+                    reuse = \"y\"\n+                if reuse in (\"\", \"y\", \"yes\"):\n+                    config_path = _update_config_for_provider(\"openai-codex\", existing.get(\"base_url\", DEFAULT_CODEX_BASE_URL))\n+                    print()\n+                    print(\"Login successful!\")\n+                    print(f\"  Config updated: {config_path} (model.provider=openai-codex)\")\n+                    return\n+            else:\n+                print(\"Existing Codex credentials are expired. Starting fresh login...\")\n+        except AuthError:\n+            pass\n+\n+    # Check for existing Codex CLI tokens we can import\n+    if not force_new_login:\n+        cli_tokens = _import_codex_cli_tokens()\n+        if cli_tokens:\n+            print(\"Found existing Codex CLI credentials at ~/.codex/auth.json\")\n+            print(\"Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.\")\n             try:\n-                reuse = input(\"Use existing credentials? [Y/n]: \").strip().lower()\n+                do_import = input(\"Import these credentials? (a separate login is recommended) [y/N]: \").strip().lower()\n             except (EOFError, KeyboardInterrupt):\n-                reuse = \"y\"\n-            if reuse in (\"\", \"y\", \"yes\"):\n-                config_path = _update_config_for_provider(\"openai-codex\", existing.get(\"base_url\", DEFAULT_CODEX_BASE_URL))\n+                do_import = \"n\"\n+            if do_import in (\"y\", \"yes\"):\n+                _save_codex_tokens(cli_tokens)\n+                base_url = os.getenv(\"HERMES_CODEX_BASE_URL\", \"\").strip().rstrip(\"/\") or DEFAULT_CODEX_BASE_URL\n+                config_path = _update_config_for_provider(\"openai-codex\", base_url)\n                 print()\n-                print(\"Login successful!\")\n+                print(\"Credentials imported. Note: if Codex CLI refreshes its token,\")\n+                print(\"Hermes will keep working independently with its own session.\")\n                 print(f\"  Config updated: {config_path} (model.provider=openai-codex)\")\n                 return\n-        else:\n-            print(\"Existing Codex credentials are expired. Starting fresh login...\")\n-    except AuthError:\n-        pass\n-\n-    # Check for existing Codex CLI tokens we can import\n-    cli_tokens = _import_codex_cli_tokens()\n-    if cli_tokens:\n-        print(\"Found existing Codex CLI credentials at ~/.codex/auth.json\")\n-        print(\"Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.\")\n-        try:\n-            do_import = input(\"Import these credentials? (a separate login is recommended) [y/N]: \").strip().lower()\n-        except (EOFError, KeyboardInterrupt):\n-            do_import = \"n\"\n-        if do_import in (\"y\", \"yes\"):\n-            _save_codex_tokens(cli_tokens)\n-            base_url = os.getenv(\"HERMES_CODEX_BASE_URL\", \"\").strip().rstrip(\"/\") or DEFAULT_CODEX_BASE_URL\n-            config_path = _update_config_for_provider(\"openai-codex\", base_url)\n-            print()\n-            print(\"Credentials imported. Note: if Codex CLI refreshes its token,\")\n-            print(\"Hermes will keep working independently with its own session.\")\n-            print(f\"  Config updated: {config_path} (model.provider=openai-codex)\")\n-            return\n \n     # Run a fresh device code flow — Hermes gets its own OAuth session\n     print()"]},{"file":"hermes_cli/main.py","hunks":["@@ -2328,7 +2328,41 @@ def _model_flow_openai_codex(config, current_model=\"\"):\n     from hermes_cli.codex_models import get_codex_model_ids\n \n     status = get_codex_auth_status()\n-    if not status.get(\"logged_in\"):\n+    if status.get(\"logged_in\"):\n+        print(\"  OpenAI Codex credentials: ✓\")\n+        print()\n+        print(\"    1. Use existing credentials\")\n+        print(\"    2. Reauthenticate (new OAuth login)\")\n+        print(\"    3. Cancel\")\n+        print()\n+        try:\n+            choice = input(\"  Choice [1/2/3]: \").strip()\n+        except (KeyboardInterrupt, EOFError):\n+            choice = \"1\"\n+\n+        if choice == \"2\":\n+            print(\"Starting a fresh OpenAI Codex login...\")\n+            print()\n+            try:\n+                mock_args = argparse.Namespace()\n+                _login_openai_codex(\n+                    mock_args,\n+                    PROVIDER_REGISTRY[\"openai-codex\"],\n+                    force_new_login=True,\n+                )\n+            except SystemExit:\n+                print(\"Login cancelled or failed.\")\n+                return\n+            except Exception as exc:\n+                print(f\"Login failed: {exc}\")\n+                return\n+            status = get_codex_auth_status()\n+            if not status.get(\"logged_in\"):\n+                print(\"Login failed.\")\n+                return\n+        elif choice == \"3\":\n+            return\n+    else:\n         print(\"Not logged into OpenAI Codex. Starting login...\")\n         print()\n         try:"]},{"file":"run_agent.py","hunks":["@@ -9532,28 +9532,47 @@ def _stop_spinner():\n                                 response_invalid = True\n                                 error_details.append(\"response is None\")\n                             else:\n-                                # output_text fallback: stream backfill may have failed\n-                                # but normalize can still recover from output_text\n-                                _out_text = getattr(response, \"output_text\", None)\n-                                _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else \"\"\n-                                if _out_text_stripped:\n-                                    logger.debug(\n-                                        \"Codex response.output is empty but output_text is present \"\n-                                        \"(%d chars); deferring to normalization.\",\n-                                        len(_out_text_stripped),\n+                                # Provider returned a terminal failure (e.g. quota exhaustion).\n+                                # Treat as invalid so the fallback chain is triggered instead of\n+                                # letting the error bubble up outside the retry/fallback loop.\n+                                _codex_resp_status = str(getattr(response, \"status\", \"\") or \"\").strip().lower()\n+                                if _codex_resp_status in {\"failed\", \"cancelled\"}:\n+                                    _codex_error_obj = getattr(response, \"error\", None)\n+                                    _codex_error_msg = (\n+                                        _codex_error_obj.get(\"message\") if isinstance(_codex_error_obj, dict)\n+                                        else str(_codex_error_obj) if _codex_error_obj\n+                                        else f\"Responses API returned status '{_codex_resp_status}'\"\n                                     )\n-                                else:\n-                                    _resp_status = getattr(response, \"status\", None)\n-                                    _resp_incomplete = getattr(response, \"incomplete_details\", None)\n-                                    logger.warning(\n-                                        \"Codex response.output is empty after stream backfill \"\n-                                        \"(status=%s, incomplete_details=%s, model=%s). %s\",\n-                                        _resp_status, _resp_incomplete,\n-                                        getattr(response, \"model\", None),\n-                                        f\"api_mode={self.api_mode} provider={self.provider}\",\n+                                    logging.warning(\n+                                        \"Codex response status='%s' (error=%s). Routing to fallback. %s\",\n+                                        _codex_resp_status, _codex_error_msg,\n+                                        self._client_log_context(),\n                                     )\n                                     response_invalid = True\n-                                    error_details.append(\"response.output is empty\")\n+                                    error_details.append(f\"response.status={_codex_resp_status}: {_codex_error_msg}\")\n+                                else:\n+                                    # output_text fallback: stream backfill may have failed\n+                                    # but normalize can still recover from output_text\n+                                    _out_text = getattr(response, \"output_text\", None)\n+                                    _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else \"\"\n+                                    if _out_text_stripped:\n+                                        logger.debug(\n+                                            \"Codex response.output is empty but output_text is present \"\n+                                            \"(%d chars); deferring to normalization.\",\n+                                            len(_out_text_stripped),\n+                                        )\n+                                    else:\n+                                        _resp_status = getattr(response, \"status\", None)\n+                                        _resp_incomplete = getattr(response, \"incomplete_details\", None)\n+                                        logger.warning(\n+                                            \"Codex response.output is empty after stream backfill \"\n+                                            \"(status=%s, incomplete_details=%s, model=%s). %s\",\n+                                            _resp_status, _resp_incomplete,\n+                                            getattr(response, \"model\", None),\n+                                            f\"api_mode={self.api_mode} provider={self.provider}\",\n+                                        )\n+                                        response_invalid = True\n+                                        error_details.append(\"response.output is empty\")\n                     elif self.api_mode == \"anthropic_messages\":\n                         _tv = self._get_transport()\n                         if not _tv.validate_response(response):"]},{"file":"scripts/release.py","hunks":["@@ -171,6 +171,8 @@\n     \"[redacted:email]\": \"satelerd\",\n     \"[redacted:email]\": \"danklynn\",\n     \"[redacted:email]\": \"MattMaximo\",\n+    \"149063006+j3ffffff@users.noreply.github.com\": \"j3ffffff\",\n+    \"A-FdL-Prog@users.noreply.github.com\": \"A-FdL-Prog\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"0xNyk@users.noreply.github.com\": \"0xNyk\","]},{"file":"tests/hermes_cli/test_auth_codex_provider.py","hunks":["@@ -4,6 +4,7 @@\n import time\n import base64\n from pathlib import Path\n+from types import SimpleNamespace\n \n import pytest\n import yaml","@@ -15,8 +16,10 @@\n     _read_codex_tokens,\n     _save_codex_tokens,\n     _import_codex_cli_tokens,\n+    _login_openai_codex,\n     get_codex_auth_status,\n     get_provider_auth_state,\n+    refresh_codex_oauth_pure,\n     resolve_codex_runtime_credentials,\n     resolve_provider,\n )","@@ -190,3 +193,161 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):\n     assert creds[\"source\"] == \"hermes-auth-store\"\n     assert creds[\"provider\"] == \"openai-codex\"\n     assert creds[\"base_url\"] == DEFAULT_CODEX_BASE_URL\n+\n+\n+class _StubHTTPResponse:\n+    def __init__(self, status_code: int, payload):\n+        self.status_code = status_code\n+        self._payload = payload\n+        self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload)\n+\n+    def json(self):\n+        if isinstance(self._payload, Exception):\n+            raise self._payload\n+        return self._payload\n+\n+\n+class _StubHTTPClient:\n+    def __init__(self, response):\n+        self._response = response\n+\n+    def __enter__(self):\n+        return self\n+\n+    def __exit__(self, *args):\n+        return False\n+\n+    def post(self, *args, **kwargs):\n+        return self._response\n+\n+\n+def _patch_httpx(monkeypatch, response):\n+    def _factory(*args, **kwargs):\n+        return _StubHTTPClient(response)\n+\n+    monkeypatch.setattr(\"hermes_cli.auth.httpx.Client\", _factory)\n+\n+\n+def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch):\n+    \"\"\"OpenAI returns {\"error\": {\"code\": \"refresh_token_reused\", \"message\": \"...\"}}\n+    — parser must surface relogin_required and the dedicated message.\n+    \"\"\"\n+    response = _StubHTTPResponse(\n+        401,\n+        {\n+            \"error\": {\n+                \"message\": \"Your refresh token has already been used to generate a new access token. Please try signing in again.\",\n+                \"type\": \"invalid_request_error\",\n+                \"param\": None,\n+                \"code\": \"refresh_token_reused\",\n+            }\n+        },\n+    )\n+    _patch_httpx(monkeypatch, response)\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        refresh_codex_oauth_pure(\"a-tok\", \"r-tok\")\n+\n+    err = exc_info.value\n+    assert err.code == \"refresh_token_reused\"\n+    assert err.relogin_required is True\n+    # The existing dedicated branch should override the message with actionable guidance.\n+    assert \"already consumed by another client\" in str(err)\n+\n+\n+def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch):\n+    \"\"\"Nested error with arbitrary code still surfaces code + message.\"\"\"\n+    response = _StubHTTPResponse(\n+        400,\n+        {\n+            \"error\": {\n+                \"message\": \"Invalid client credentials.\",\n+                \"type\": \"invalid_request_error\",\n+                \"code\": \"invalid_client\",\n+            }\n+        },\n+    )\n+    _patch_httpx(monkeypatch, response)\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        refresh_codex_oauth_pure(\"a-tok\", \"r-tok\")\n+\n+    err = exc_info.value\n+    assert err.code == \"invalid_client\"\n+    assert \"Invalid client credentials.\" in str(err)\n+\n+\n+def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch):\n+    \"\"\"Fallback path: OAuth spec-shape {\"error\": \"invalid_grant\", \"error_description\": \"...\"}\n+    must still map to relogin_required=True via the existing code set.\n+    \"\"\"\n+    response = _StubHTTPResponse(\n+        400,\n+        {\n+            \"error\": \"invalid_grant\",\n+            \"error_description\": \"Refresh token is expired or revoked.\",\n+        },\n+    )\n+    _patch_httpx(monkeypatch, response)\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        refresh_codex_oauth_pure(\"a-tok\", \"r-tok\")\n+\n+    err = exc_info.value\n+    assert err.code == \"invalid_grant\"\n+    assert err.relogin_required is True\n+    assert \"Refresh token is expired or revoked.\" in str(err)\n+\n+\n+def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch):\n+    \"\"\"No JSON body → generic 'with status 401' message; 401 always forces relogin.\"\"\"\n+    response = _StubHTTPResponse(401, ValueError(\"not json\"))\n+    _patch_httpx(monkeypatch, response)\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        refresh_codex_oauth_pure(\"a-tok\", \"r-tok\")\n+\n+    err = exc_info.value\n+    assert err.code == \"codex_refresh_failed\"\n+    # 401/403 from the token endpoint always means the refresh token is\n+    # invalid/expired — force relogin even without a parseable error body.\n+    assert err.relogin_required is True\n+    assert \"status 401\" in str(err)\n+\n+\n+def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch):\n+    called = {\"device_login\": 0}\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_codex_runtime_credentials\",\n+        lambda: {\"base_url\": DEFAULT_CODEX_BASE_URL},\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth._import_codex_cli_tokens\",\n+        lambda: {\"access_token\": \"[redacted:secret-assignment]\", \"refresh_token\": \"[redacted:secret-assignment]\"},\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth._codex_device_code_login\",\n+        lambda: {\n+            \"tokens\": {\"access_token\": \"[redacted:secret-assignment]\", \"refresh_token\": \"[redacted:secret-assignment]\"},\n+            \"last_refresh\": \"2026-04-01T00:00:00Z\",\n+            \"base_url\": DEFAULT_CODEX_BASE_URL,\n+        },\n+    )\n+\n+    def _fake_save(tokens, last_refresh=None):\n+        called[\"device_login\"] += 1\n+        called[\"tokens\"] = dict(tokens)\n+        called[\"last_refresh\"] = last_refresh\n+\n+    monkeypatch.setattr(\"hermes_cli.auth._save_codex_tokens\", _fake_save)\n+    monkeypatch.setattr(\"hermes_cli.auth._update_config_for_provider\", lambda *args, **kwargs: \"/tmp/config.yaml\")\n+    monkeypatch.setattr(\n+        \"builtins.input\",\n+        lambda prompt=\"\": (_ for _ in ()).throw(AssertionError(\"force_new_login should not prompt for reuse/import\")),\n+    )\n+\n+    _login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY[\"openai-codex\"], force_new_login=True)\n+\n+    assert called[\"device_login\"] == 1\n+    assert called[\"tokens\"][\"access_token\"] == \"fresh-at\""]},{"file":"tests/hermes_cli/test_codex_models.py","hunks":["@@ -72,7 +72,9 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):\n     from hermes_cli.main import _model_flow_openai_codex\n \n     captured = {}\n+    choices = iter([\"1\"])\n \n+    monkeypatch.setattr(\"builtins.input\", lambda prompt=\"\": next(choices))\n     monkeypatch.setattr(\n         \"hermes_cli.auth.get_codex_auth_status\",\n         lambda: {\"logged_in\": True},","@@ -107,6 +109,83 @@ def _fake_prompt_model_selection(model_ids, current_model=\"\"):\n     assert captured[\"current_model\"] == \"openai/gpt-5.4\"\n \n \n+def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys):\n+    from hermes_cli.main import _model_flow_openai_codex\n+\n+    captured = {\"login_calls\": 0}\n+    choices = iter([\"2\"])\n+\n+    monkeypatch.setattr(\"builtins.input\", lambda prompt=\"\": next(choices))\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.get_codex_auth_status\",\n+        lambda: {\"logged_in\": True, \"source\": \"hermes-auth-store\"},\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_codex_runtime_credentials\",\n+        lambda *args, **kwargs: {\"api_key\": \"[redacted:secret-assignment]\"},\n+    )\n+\n+    def _fake_login(*args, force_new_login=False, **kwargs):\n+        captured[\"login_calls\"] += 1\n+        captured[\"force_new_login\"] = force_new_login\n+\n+    monkeypatch.setattr(\"hermes_cli.auth._login_openai_codex\", _fake_login)\n+    monkeypatch.setattr(\n+        \"hermes_cli.codex_models.get_codex_model_ids\",\n+        lambda access_token=None: [\"gpt-5.4\", \"gpt-5.3-codex\"],\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth._prompt_model_selection\",\n+        lambda model_ids, current_model=\"\": None,\n+    )\n+\n+    _model_flow_openai_codex({}, current_model=\"gpt-5.4\")\n+\n+    out = capsys.readouterr().out\n+    assert \"Use existing credentials\" in out\n+    assert \"Reauthenticate (new OAuth login)\" in out\n+    assert captured[\"login_calls\"] == 1\n+    assert captured[\"force_new_login\"] is True\n+\n+\n+def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):\n+    from hermes_cli.main import _model_flow_openai_codex\n+\n+    choices = iter([\"1\"])\n+    captured = {}\n+\n+    monkeypatch.setattr(\"builtins.input\", lambda prompt=\"\": next(choices))\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.get_codex_auth_status\",\n+        lambda: {\"logged_in\": True, \"source\": \"hermes-auth-store\"},\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_codex_runtime_credentials\",\n+        lambda *args, **kwargs: {\"api_key\": \"[redacted:secret-assignment]\"},\n+    )\n+\n+    def _fake_get_codex_model_ids(access_token=[redacted:secret-assignment]\n+        captured[\"access_token\"] = access_token\n+        return [\"gpt-5.4\"]\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.codex_models.get_codex_model_ids\",\n+        _fake_get_codex_model_ids,\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth._prompt_model_selection\",\n+        lambda model_ids, current_model=\"\": None,\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth._login_openai_codex\",\n+        lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError(\"should not reauthenticate\")),\n+    )\n+\n+    _model_flow_openai_codex({}, current_model=\"gpt-5.4\")\n+\n+    assert captured[\"access_token\"] == \"existing-codex-token\"\n+\n+\n # ── Tests for _normalize_model_for_provider ──────────────────────────\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}