diff --git a/agent/credential_pool.py b/agent/credential_pool.py index bff262bdc..07afa9609 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1128,6 +1128,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup elif provider == "openai-codex": state = _load_provider_state(auth_store, "openai-codex") tokens = state.get("tokens") if isinstance(state, dict) else None + # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth + # store has no tokens. This mirrors resolve_codex_runtime_credentials() + # so that load_pool() and list_authenticated_providers() detect tokens + # that only exist in the Codex CLI shared file. + if not (isinstance(tokens, dict) and tokens.get("access_token")): + try: + from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens + cli_tokens = _import_codex_cli_tokens() + if cli_tokens: + logger.info("Importing Codex CLI tokens into Hermes auth store.") + _save_codex_tokens(cli_tokens) + # Re-read state after import + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") + tokens = state.get("tokens") if isinstance(state, dict) else None + except Exception as exc: + logger.debug("Codex CLI token import failed: %s", exc) if isinstance(tokens, dict) and tokens.get("access_token"): active_sources.add("device_code") changed |= _upsert_entry( diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 273da0871..988983dba 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -839,8 +839,11 @@ def list_authenticated_providers( if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars): has_creds = True break - if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): - # These use auth stores, not env vars — check for auth.json entries + # Check auth store and credential pool for non-env-var credentials. + # This applies to OAuth providers AND api_key providers that also + # support OAuth (e.g. anthropic supports both API key and Claude Code + # OAuth via external credential files). + if not has_creds: try: from hermes_cli.auth import _load_auth_store store = _load_auth_store() @@ -853,6 +856,38 @@ def list_authenticated_providers( has_creds = True except Exception as exc: logger.debug("Auth store check failed for %s: %s", pid, exc) + # Fallback: check the credential pool with full auto-seeding. + # This catches credentials that exist in external stores (e.g. + # Codex CLI ~/.codex/auth.json) which _seed_from_singletons() + # imports on demand but aren't in the raw auth.json yet. + if not has_creds: + try: + from agent.credential_pool import load_pool + pool = load_pool(hermes_slug) + if pool.has_credentials(): + has_creds = True + except Exception as exc: + logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) + # Fallback: check external credential files directly. + # The credential pool gates anthropic behind + # is_provider_explicitly_configured() to prevent auxiliary tasks + # from silently consuming Claude Code tokens (PR #4210). + # But the /model picker is discovery-oriented — we WANT to show + # providers the user can switch to, even if they aren't currently + # configured. + if not has_creds and hermes_slug == "anthropic": + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + read_hermes_oauth_credentials, + ) + hermes_creds = read_hermes_oauth_credentials() + cc_creds = read_claude_code_credentials() + if (hermes_creds and hermes_creds.get("accessToken")) or \ + (cc_creds and cc_creds.get("accessToken")): + has_creds = True + except Exception as exc: + logger.debug("Anthropic external creds check failed: %s", exc) if not has_creds: continue diff --git a/tests/hermes_cli/test_codex_cli_model_picker.py b/tests/hermes_cli/test_codex_cli_model_picker.py new file mode 100644 index 000000000..2af837fde --- /dev/null +++ b/tests/hermes_cli/test_codex_cli_model_picker.py @@ -0,0 +1,241 @@ +"""Regression test: openai-codex must appear in /model picker when +credentials are only in the Codex CLI shared file (~/.codex/auth.json) +and haven't been migrated to the Hermes auth store yet. + +Root cause: list_authenticated_providers() checked the raw Hermes auth +store but didn't know about the Codex CLI fallback import path. + +Fix: _seed_from_singletons() now imports from the Codex CLI when the +Hermes auth store has no openai-codex tokens, and +list_authenticated_providers() falls back to load_pool() for OAuth +providers. +""" + +import base64 +import json +import os +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def _make_fake_jwt(expiry_offset: int = 3600) -> str: + """Build a fake JWT with a future expiry.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() + exp = int(time.time()) + expiry_offset + payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode() + payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode() + return f"{header}.{payload}.fakesig" + + +@pytest.fixture() +def codex_cli_only_env(tmp_path, monkeypatch): + """Set up an environment where Codex tokens exist only in ~/.codex/auth.json, + NOT in the Hermes auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + codex_home = tmp_path / ".codex" + codex_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Empty Hermes auth store + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Valid Codex CLI tokens + fake_jwt = _make_fake_jwt() + (codex_home / "auth.json").write_text( + json.dumps({ + "tokens": { + "access_token": fake_jwt, + "refresh_token": "fake-refresh-token", + } + }) + ) + + # Clear provider env vars so only OAuth is a detection path + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env): + """openai-codex should appear when tokens only exist in ~/.codex/auth.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs, ( + f"openai-codex not found in /model picker providers: {slugs}" + ) + + codex = next(p for p in providers if p["slug"] == "openai-codex") + assert codex["is_current"] is True + assert codex["total_models"] > 0 + + +def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env): + """After the /model picker detects Codex CLI tokens, they should be + migrated into the Hermes auth store for subsequent fast lookups.""" + from hermes_cli.model_switch import list_authenticated_providers + + # First call triggers migration + list_authenticated_providers(current_provider="openai-codex") + + # Verify tokens are now in Hermes auth store + auth_path = codex_cli_only_env / "auth.json" + store = json.loads(auth_path.read_text()) + providers = store.get("providers", {}) + assert "openai-codex" in providers, ( + f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}" + ) + tokens = providers["openai-codex"].get("tokens", {}) + assert tokens.get("access_token"), "access_token missing after migration" + assert tokens.get("refresh_token"), "refresh_token missing after migration" + + +@pytest.fixture() +def hermes_auth_only_env(tmp_path, monkeypatch): + """Tokens already in Hermes auth store (no Codex CLI needed).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Point CODEX_HOME to nonexistent dir to prove it's not needed + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 2, + "providers": { + "openai-codex": { + "tokens": { + "access_token": _make_fake_jwt(), + "refresh_token": "fake-refresh", + }, + "last_refresh": "2026-04-12T00:00:00Z", + } + }, + })) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_normal_path_still_works(hermes_auth_only_env): + """openai-codex appears when tokens are already in Hermes auth store.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs + + +@pytest.fixture() +def claude_code_only_env(tmp_path, monkeypatch): + """Set up an environment where Anthropic credentials only exist in + ~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes + auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # No Codex CLI + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Claude Code credentials in the correct format + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / ".credentials.json").write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": _make_fake_jwt(), + "refreshToken": "fake-refresh", + "expiresAt": int(time.time() * 1000) + 3_600_000, + } + })) + + # Patch Path.home() so the adapter finds the file + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_claude_code_file_detected_by_model_picker(claude_code_only_env): + """anthropic should appear when credentials only exist in ~/.claude/.credentials.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="anthropic", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "anthropic" in slugs, ( + f"anthropic not found in /model picker providers: {slugs}" + ) + + anthropic = next(p for p in providers if p["slug"] == "anthropic") + assert anthropic["is_current"] is True + assert anthropic["total_models"] > 0 + + +def test_no_codex_when_no_credentials(tmp_path, monkeypatch): + """openai-codex should NOT appear when no credentials exist anywhere.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openrouter", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" not in slugs, ( + "openai-codex should not appear without any credentials" + )