# NousResearch/hermes-agent · Codex desktop OAuth login and runtime issues

## Open now

- Nothing open on GitHub.

These members concern bugs and fixes related to OAuth authentication flow on the desktop client, specifically around credential persistence paths and runtime provider resolution after a successful login.

## TL;DR

- 4 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #37517. Verify it addresses your case.
- **Recommended:** read PR #37517 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.39`: the share of member 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.

## Closed / duplicate issues

- #37515: Bug: Desktop (Windows) runtime fails to resolve provider despite OAuth login succeeding and setup.status reporting credentials configured
- #49159: \[Bug\]:  Open AI Auth problem

## Prior & competing PRs

- **#37517: fix(auth): align Codex OAuth persistence paths**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/credential_pool.py`, `hermes_cli/auth.py`, `hermes_cli/auth_commands.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_auth_commands.py`, `tests/hermes_cli/test_web_oauth_dispatch.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -1891,6 +1891,7 @@ def _env_val(key: str) -> str:
             # via `hermes auth openai-codex`.
             if isinstance(tokens, dict) and tokens.get("access_token"):
                 active_sources.add("device_code")
    +            custom_label = str(state.get("label") or "").strip()
                 changed |= _upsert_entry(
                     entries,
                     provider,
    ```

    ```diff
    @@ -1902,7 +1903,7 @@ def _env_val(key: str) -> str:
                         "refresh_token": tokens.get("refresh_token"),
                         "base_url": "https://chatgpt.com/backend-api/codex",
                         "last_refresh": state.get("last_refresh"),
    -                    "label": label_from_token(tokens.get("access_token", ""), "device_code"),
    +                    "label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
                     },
                 )
     
    ```
  - `hermes_cli/auth.py`

    ```diff
    @@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
             entry["last_error_reset_at"] = None
     
     
    -def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None:
    +def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None, label: str = None) -> None:
         """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
         if last_refresh is None:
             last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
    ```

    ```diff
    @@ -3380,6 +3380,8 @@ def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None
             state["tokens"] = tokens
             state["last_refresh"] = last_refresh
             state["auth_mode"] = "chatgpt"
    +        if label and str(label).strip():
    +            state["label"] = str(label).strip()
             _save_provider_state(auth_store, "openai-codex", state)
             _sync_codex_pool_entries(auth_store, tokens, last_refresh)
             _save_auth_store(auth_store)
    ```
  - `hermes_cli/auth_commands.py`

    ```diff
    @@ -307,28 +307,20 @@ def auth_add_command(args) -> None:
             return
     
         if provider == "openai-codex":
    -        # Clear any existing suppression marker so a re-link after `hermes auth
    -        # remove openai-codex` works without the new tokens being skipped.
    -        auth_mod.unsuppress_credential_source(provider, "device_code")
             creds = auth_mod._codex_device_code_login()
             label = (getattr(args, "label", None) or "").strip() or label_from_token(
                 creds["tokens"]["access_token"],
                 _oauth_default_label(provider, len(pool.entries()) + 1),
             )
    -        entry = PooledCredential(
    -            provider=provider,
    -            id=uuid.uuid4().hex[:6],
    -            label=label,
    -            auth_type=AUTH_TYPE_OAUTH,
    -            priority=0,
    -            source=f"{SOURCE_MANUAL}:device_code",
    -            access_token=[redacted:secret-assignment]"tokens"]["access_token"],
    -            refresh_token=[redacted:secret-assignment]"tokens"].get("refresh_token"),
    -            base_url=creds.get("base_url"),
    +        auth_mod._save_codex_tokens(
    +            creds["tokens"],
                 last_refresh=creds.get("last_refresh"),
    +            label=label,
             )
    -        pool.add_entry(entry)
    -        print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
    +        pool = load_pool(provider)
    +        entry = next((item for item in pool.entries() if item.source == "device_code"), None)
    +        shown_label = entry.label if entry is not None else label
    +        print(f'Saved {provider} OAuth device-code credentials: "[redacted:secret-assignment]"')
             return
     
         if provider == "xai-oauth":
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -3717,31 +3717,12 @@ def _codex_full_login_worker(session_id: str) -> None:
             if not access_token:
                 raise RuntimeError("token exchange did not return access_token")
     
    -        # Persist via credential pool — same shape as auth_commands.add_command
    -        from agent.credential_pool import (
    -            PooledCredential,
    -            load_pool,
    -            AUTH_TYPE_OAUTH,
    -            SOURCE_MANUAL,
    -        )
    -        import uuid as _uuid
    -        pool = load_pool("openai-codex")
    -        base_url = (
    -            os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
    -            or DEFAULT_CODEX_BASE_URL
    -        )
    -        entry = PooledCredential(
    -            provider="openai-codex",
    -            id=_uuid.uuid4().hex[:6],
    -            label="dashboard device_code",
    -            auth_type=AUTH_TYPE_OAUTH,
    -            priority=0,
    -            source=f"{SOURCE_MANUAL}:dashboard_device_code",
    -            access_token=[redacted:secret-assignment]
    -            refresh_token=[redacted:secret-assignment]
    -            base_url=base_url,
    -        )
    -        pool.add_entry(entry)
    +        from hermes_cli.auth import _save_codex_tokens
    +
    +        _save_codex_tokens({
    +            "access_token": access_token,
    +            "refresh_token": refresh_token,
    +        })
             with _oauth_sessions_lock:
                 sess["status"] = "approved"
             _log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
    ```
  - `tests/hermes_cli/test_auth_commands.py`

    ```diff
    @@ -303,9 +303,11 @@ class _Args:
     
         payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
         entries = payload["credential_pool"]["openai-codex"]
    -    entry = next(item for item in entries if item["source"] == "manual:device_code")
    +    entry = next(item for item in entries if item["source"] == "device_code")
    +    assert payload["active_provider"] == "openai-codex"
    +    assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
         assert entry["label"] == "[redacted:email]"
    -    assert entry["source"] == "manual:device_code"
    +    assert entry["source"] == "device_code"
         assert entry["refresh_token"] == "refresh-token"
         assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
     
    ```

    ```diff
    @@ -1129,10 +1131,6 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
     def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
         """Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
         monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
    -    monkeypatch.setattr(
    -        "agent.credential_pool._seed_from_singletons",
    -        lambda provider, entries: (False, set()),
    -    )
         hermes_home = tmp_path / "hermes"
         hermes_home.mkdir(parents=True, exist_ok=True)
     
    ```

    ```diff
    @@ -1171,7 +1169,8 @@ class _Args:
         assert "openai-codex" not in payload.get("suppressed_sources", {})
         # New pool entry must be present
         entries = payload["credential_pool"]["openai-codex"]
    -    assert any(e["source"] == "manual:device_code" for e in entries)
    +    assert any(e["source"] == "device_code" for e in entries)
    +    assert payload["active_provider"] == "openai-codex"
     
     
     def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
    ```
  - `tests/hermes_cli/test_web_oauth_dispatch.py`

    ```diff
    @@ -146,6 +146,67 @@ def fake_request_device_code(**kwargs):
         assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
     
     
    +def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch):
    +    from hermes_cli import web_server as ws
    +    from hermes_cli.auth import get_active_provider
    +    from hermes_cli.runtime_provider import resolve_runtime_provider
    +
    +    access_token = "[redacted:secret-assignment]"
    +
    +    class _Resp:
    +        def __init__(self, status_code, payload):
    +            self.status_code = status_code
    +            self._payload = payload
    +
    +        def json(self):
    +            return self._payload
    +
    +    class _Client:
    +        def __init__(self, *args, **kwargs):
    +            pass
    +
    +        def __enter__(self):
    +            return self
    +
    +        def __exit__(self, *args):
    +            return False
    +
    +        def post(self, url, **kwargs):
    +            if url.endswith("/deviceauth/usercode"):
    +                return _Resp(200, {
    +                    "device_auth_id": "device-auth-id",
    +                    "interval": 3,
    +                    "user_code": "CODEX-1234",
    +                })
    +            if url.endswith("/deviceauth/token"):
    +                return _Resp(200, {
    +                    "authorization_code": "authorization-code",
    +                    "code_verifier": "code-verifier",
    +                })
    +            return _Resp(200, {
    +                "access_token": access_token,
    +                "refresh_token": "codex-refresh",
    +            })
    +
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +    monkeypatch.setattr(httpx, "Client", _Client)
    +    monkeypatch.setattr(ws.time, "sleep", lambda _: None)
    +
    +    sid, _ = ws._new_oauth_session("openai-codex", "device_code")
    +    try:
    +        ws._codex_full_login_worker(sid)
    +
    +        assert ws._oauth_sessions[sid]["status"] == "approved"
    +        assert get_active_provider() == "openai-codex"
    +
    +        runtime = resolve_runtime_provider(requested=None)
    +        assert runtime["provider"] == "openai-codex"
    +        assert runtime["api_key"] == access_token
    +        assert runtime["api_mode"] == "codex_responses"
    +    finally:
    +        ws._oauth_sessions.pop(sid, None)
    +
    +
     def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
         from hermes_cli import auth as auth_mod
         from hermes_cli import web_server as ws
    ```
- **#37525: fix: select Codex runtime after desktop OAuth**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#37517`

## Fix status

- Merged to main in PR #37517 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-02`.
- Subsystem: `agent`
