From 271f0e6eb0d884ab7001a27a93d7b45869ae44d0 Mon Sep 17 00:00:00 2001 From: Matt Maximo Date: Wed, 1 Apr 2026 21:25:26 -0400 Subject: [PATCH] fix(model): let Codex setup reuse or reauthenticate --- hermes_cli/auth.py | 91 +++++++++++--------- hermes_cli/main.py | 36 +++++++- tests/hermes_cli/test_auth_codex_provider.py | 48 ++++++++++- tests/hermes_cli/test_codex_models.py | 79 +++++++++++++++++ 4 files changed, 208 insertions(+), 46 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index acb60db6c..dbce736cc 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3097,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.") - 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 + 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 - 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 + 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: + 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() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9a21cfa44..0ded14c38 100644 --- a/hermes_cli/main.py +++ b/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: diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 3d2598379..ad5ce40f3 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -4,6 +4,7 @@ import json import time import base64 from pathlib import Path +from types import SimpleNamespace import pytest import yaml @@ -15,6 +16,7 @@ from hermes_cli.auth import ( _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, @@ -244,7 +246,7 @@ def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypat _patch_httpx(monkeypatch, response) with pytest.raises(AuthError) as exc_info: - refresh_codex_oauth_pure("access-old", "refresh-old") + refresh_codex_oauth_pure("a-tok", "r-tok") err = exc_info.value assert err.code == "refresh_token_reused" @@ -268,7 +270,7 @@ def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch): _patch_httpx(monkeypatch, response) with pytest.raises(AuthError) as exc_info: - refresh_codex_oauth_pure("access-old", "refresh-old") + refresh_codex_oauth_pure("a-tok", "r-tok") err = exc_info.value assert err.code == "invalid_client" @@ -289,7 +291,7 @@ def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch): _patch_httpx(monkeypatch, response) with pytest.raises(AuthError) as exc_info: - refresh_codex_oauth_pure("access-old", "refresh-old") + refresh_codex_oauth_pure("a-tok", "r-tok") err = exc_info.value assert err.code == "invalid_grant" @@ -303,7 +305,7 @@ def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): _patch_httpx(monkeypatch, response) with pytest.raises(AuthError) as exc_info: - refresh_codex_oauth_pure("access-old", "refresh-old") + refresh_codex_oauth_pure("a-tok", "r-tok") err = exc_info.value assert err.code == "codex_refresh_failed" @@ -311,3 +313,41 @@ def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): # 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": "cli-at", "refresh_token": "cli-rt"}, + ) + monkeypatch.setattr( + "hermes_cli.auth._codex_device_code_login", + lambda: { + "tokens": {"access_token": "fresh-at", "refresh_token": "fresh-rt"}, + "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" diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index cffce2a0e..949d1c8e2 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/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 test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch): 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": "fresh-codex-token"}, + ) + + 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": "existing-codex-token"}, + ) + + def _fake_get_codex_model_ids(access_token=None): + 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 ──────────────────────────