mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(model): let Codex setup reuse or reauthenticate
This commit is contained in:
parent
813dbd9b40
commit
271f0e6eb0
4 changed files with 208 additions and 46 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue