From bb0619dbcea7a84fc3c33e027ba71a4033a08748 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 2 Jun 2026 12:19:44 -0500 Subject: [PATCH] fix(auth): align Codex OAuth persistence paths (#37517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): codex OAuth onboarding now resolves on fresh install The desktop codex device-code worker persisted tokens with a hand-rolled pool.add_entry(), writing only credential_pool.openai-codex. It never set active_provider, so on a fresh install the onboarding setup.runtime_check resolved provider "auto", couldn't detect the Codex OAuth session, and raised "No inference provider configured" — while setup.status (which sniffs the pool) reported configured. The disagreement surfaced as the onboarding banner "Connected, but Hermes still cannot resolve a usable provider." Use the canonical _save_codex_tokens() instead, matching the CLI's `hermes auth add openai-codex` path and the Nous/MiniMax dashboard workers. It writes the providers.openai-codex singleton (setting active_provider) and syncs the pool. * fix(auth): align Codex OAuth persistence paths Ensure desktop and CLI Codex OAuth logins both write the canonical provider state so fresh installs resolve a usable runtime provider. --------- Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- agent/credential_pool.py | 3 +- hermes_cli/auth.py | 4 +- hermes_cli/auth_commands.py | 22 +++----- hermes_cli/web_server.py | 31 ++--------- tests/hermes_cli/test_auth_commands.py | 13 ++--- tests/hermes_cli/test_web_oauth_dispatch.py | 61 +++++++++++++++++++++ 6 files changed, 85 insertions(+), 49 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 5eab3bdb8..e5b473ec5 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1891,6 +1891,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup # 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, @@ -1902,7 +1903,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup "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"), }, ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 97f51886f..7bde989ff 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries( entry["last_error_reset_at"] = None -def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: +def _save_codex_tokens(tokens: Dict[str, 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") @@ -3380,6 +3380,8 @@ def _save_codex_tokens(tokens: Dict[str, 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) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index bb791e705..0ca562762 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -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=creds["tokens"]["access_token"], - refresh_token=creds["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: "{shown_label}"') return if provider == "xai-oauth": diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4a8e5d45e..fc868227a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3729,31 +3729,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=access_token, - refresh_token=refresh_token, - 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) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index ae95c2747..f6fc408a0 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -303,9 +303,11 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch): 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"] == "codex@example.com" - 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" @@ -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) @@ -1171,7 +1169,8 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch): 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): diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 0c6b902f7..f10c711f4 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -146,6 +146,67 @@ def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusa 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 = "h.eyJleHAiOjk5OTk5OTk5OTl9.s" + + 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