Codex authentication handling and stale CLI auth sync in Hermes
Cluster 6651. Data as of
Nothing open on GitHub. Fix merged in #15104 on 24 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #15104. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members all concern Codex auth in Hermes: one bug report about Codex CLI sync not recovering stale Hermes auth state for gateway/auxiliary flows, and three fix PRs addressing related areas (stale CLI auth sync recovery, OAuth error parsing with fallback routing and reauth UX, and Hermes managing its own auth rather than touching ~/.codex/auth.json). Whether the changes are complementary or conflicting is not determinable from the titles alone.
Open issues0
Open PRs0
Fixes on record
-
#12360 fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json
files:
agent/credential_pool.py,hermes_cli/auth.py,tests/hermes_cli/test_auth_codex_provider.pyShow the diff
-
agent/credential_pool.py@@ -22,8 +22,6 @@ _auth_store_lock, _codex_access_token_is_expiring, _decode_jwt_claims, - _import_codex_cli_tokens, - _write_codex_cli_tokens, _load_auth_store, _load_provider_state, _resolve_kimi_base_url, @@ -457,39 +455,6 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) - logger.debug("Failed to sync from credentials file: %s", exc) return entry - def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential: - """Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ. - - OpenAI OAuth refresh tokens are single-use and rotate on every refresh. - When the Codex CLI (or another Hermes profile) refreshes its token, - the pool entry's refresh_token becomes stale. This method detects that - by comparing against ~/.codex/auth.json and syncing the fresh pair. - """ - if self.provider != "openai-codex": - return entry - try: - cli_tokens = _import_codex_cli_tokens() - if not cli_tokens: - return entry - cli_refresh = cli_tokens.get("refresh_token", "") - cli_access = cli_tokens.get("access_token", "") - if cli_refresh and cli_refresh != entry.refresh_token: - logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id) - updated = replace( - entry, - access_token=[redacted:secret-assignment] - refresh_token=[redacted:secret-assignment] - last_status=None, - last_status_at=None, - last_error_code=None, - ) - self._replace_entry(entry, updated) - self._persist() - return updated - except Exception as exc: - logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc) - return entry - def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None: """Write refreshed pool entry tokens back to auth.json providers. @@ -585,13 +550,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po except Exception as wexc: logger.debug("Failed to write refreshed token to credentials file: %s", wexc) elif self.provider == "openai-codex": - # Proactively sync from ~/.codex/auth.json before refresh. - # The Codex CLI (or another Hermes profile) may have already - # consumed our refresh_token. Syncing first avoids a - # "refresh_token_reused" error when the CLI has a newer pair. - synced = self._sync_codex_entry_from_cli(entry) - if synced is not entry: - entry = synced refreshed = auth_mod.refresh_codex_oauth_pure( entry.access_token, entry.refresh_token, @@ -677,45 +635,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # Credentials file had a valid (non-expired) token — use it directly logger.debug("Credentials file has valid token, using without refresh") return synced - # For openai-codex: the refresh_token may have been consumed by - # the Codex CLI between our proactive sync and the refresh call. - # Re-sync and retry once. - if self.provider == "openai-codex": - synced = self._sync_codex_entry_from_cli(entry) - if synced.refresh_token != entry.refresh_token: - logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json") - try: - refreshed = auth_mod.refresh_codex_oauth_pure( - synced.access_token, - synced.refresh_token, - ) - updated = replace( - synced, - access_token=[redacted:secret-assignment]"access_token"], - refresh_token=[redacted:secret-assignment]"refresh_token"], - last_refresh=refreshed.get("last_refresh"), - last_status=STATUS_OK, - last_status_at=None, - last_error_code=None, - ) - self._replace_entry(synced, updated) - self._persist() - self._sync_device_code_entry_to_auth_store(updated) - try: - _write_codex_cli_tokens( - updated.access_token, - updated.refresh_token, - last_refresh=updated.last_refresh, - ) - except Exception as wexc: - logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc) - return updated - except Exception as retry_exc: - logger.debug("Codex retry refresh also failed: %s", retry_exc) - elif not self._entry_needs_refresh(synced): - logger.debug("Codex CLI has valid token, using without refresh") - self._sync_device_code_entry_to_auth_store(synced) - return synced self._mark_exhausted(entry, None) return None @@ -734,17 +653,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # _seed_from_singletons() on the next load_pool() sees fresh state # instead of re-seeding stale/consumed tokens. self._sync_device_code_entry_to_auth_store(updated) - # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI - # and VS Code don't hit "refresh_token_reused" on their next refresh. - if self.provider == "openai-codex": - try: - _write_codex_cli_tokens( - updated.access_token, - updated.refresh_token, - last_refresh=updated.last_refresh, - ) - except Exception as wexc: - logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: @@ -790,16 +698,6 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal if synced is not entry: entry = synced cleared_any = True - # For openai-codex entries, sync from ~/.codex/auth.json before - # any status/refresh checks. This picks up tokens refreshed by - # the Codex CLI or another Hermes profile. - if (self.provider == "openai-codex" - and entry.last_status == STATUS_EXHAUSTED - and entry.refresh_token): - synced = self._sync_codex_entry_from_cli(entry) - if synced is not entry: - entry = synced - cleared_any = True if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: @@ -1218,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup elif provider == "openai-codex": # Respect user suppression — `hermes auth remove openai-codex` marks # the device_code source as suppressed so it won't be re-seeded from - # either the Hermes auth store or ~/.codex/auth.json. Without this - # gate the removal is instantly undone on the next load_pool() call. + # the Hermes auth store. Without this gate the removal is instantly + # undone on the next load_pool() call. codex_suppressed = False try: from hermes_cli.auth import is_source_suppressed @@ -1231,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup 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) + # Hermes owns its own Codex auth state — we do NOT auto-import from + # ~/.codex/auth.json at pool-load time. OAuth refresh tokens are + # single-use, so sharing them with Codex CLI / VS Code causes + # refresh_token_reused race failures. Users who want to adopt + # existing Codex CLI credentials get a one-time, explicit prompt + # via `hermes auth openai-codex`. if isinstance(tokens, dict) and tokens.get("access_token"): active_sources.add("device_code") changed |= _upsert_entry( -
hermes_cli/auth.py@@ -1434,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } -def _write_codex_cli_tokens( - access_token: str, - refresh_token: str, - *, - last_refresh: Optional[str] = None, -) -> None: - """Write refreshed tokens back to ~/.codex/auth.json. - - OpenAI OAuth refresh tokens are single-use and rotate on every refresh. - When Hermes refreshes a token it consumes the old refresh_token; if we - don't write the new pair back, the Codex CLI (or VS Code extension) will - fail with ``refresh_token_reused`` on its next refresh attempt. - - This mirrors the Anthropic write-back to ~/.claude/.credentials.json - via ``_write_claude_code_credentials()``. - """ - codex_home = os.getenv("CODEX_HOME", "").strip() - if not codex_home: - codex_home = str(Path.home() / ".codex") - auth_path = Path(codex_home).expanduser() / "auth.json" - try: - existing: Dict[str, Any] = {} - if auth_path.is_file(): - existing = json.loads(auth_path.read_text(encoding="utf-8")) - if not isinstance(existing, dict): - existing = {} - - tokens_dict = existing.get("tokens") - if not isinstance(tokens_dict, dict): - tokens_dict = {} - tokens_dict["access_token"] = access_token - tokens_dict["refresh_token"] = refresh_token - existing["tokens"] = tokens_dict - if last_refresh is not None: - existing["last_refresh"] = last_refresh - - auth_path.parent.mkdir(parents=True, exist_ok=True) - auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") - auth_path.chmod(0o600) - except (OSError, IOError) as exc: - logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc) - - def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -1604,12 +1561,6 @@ def _refresh_codex_auth_tokens( updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) - # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync. - _write_codex_cli_tokens( - refreshed["access_token"], - refreshed["refresh_token"], - last_refresh=refreshed.get("last_refresh"), - ) return updated_tokens @@ -1654,25 +1605,7 @@ def resolve_codex_runtime_credentials( refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) -> Dict[str, Any]: """Resolve runtime credentials from Hermes's own Codex token store.""" - try: - data = _read_codex_tokens() - except AuthError as orig_err: - # Only attempt migration when there are NO tokens stored at all - # (code == "codex_auth_missing"), not when tokens exist but are invalid. - if orig_err.code != "codex_auth_missing": - raise - - # Migration: user had Codex as active provider with old storage (~/.codex/). - cli_tokens = _import_codex_cli_tokens() - if cli_tokens: - logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store") - print("⚠️ Migrating Codex credentials to Hermes's own auth store.") - print(" This avoids conflicts with Codex CLI and VS Code.") - print(" Run `hermes auth` to create a fully independent session.\n") - _save_codex_tokens(cli_tokens) - data = _read_codex_tokens() - else: - raise + data = _read_codex_tokens() tokens = [redacted:secret-assignment]"tokens"]) access_token = [redacted:secret-assignment]"access_token", "") or "").strip() refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20")) -
tests/hermes_cli/test_auth_codex_provider.py@@ -14,7 +14,6 @@ PROVIDER_REGISTRY, _read_codex_tokens, _save_codex_tokens, - _write_codex_cli_tokens, _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, @@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): assert data["tokens"]["access_token"] == "hermes-at" -def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch): - """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.""" - codex_home = tmp_path / "codex-cli" - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z") - - auth_path = codex_home / "auth.json" - assert auth_path.exists() - data = json.loads(auth_path.read_text()) - assert data["tokens"]["access_token"] == "new-access" - assert data["tokens"]["refresh_token"] == "new-refresh" - assert data["last_refresh"] == "2026-04-12T00:00:00Z" - # Verify file permissions are restricted - assert (auth_path.stat().st_mode & 0o777) == 0o600 - - -def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch): - """_write_codex_cli_tokens preserves extra fields in existing auth.json.""" - codex_home = tmp_path / "codex-cli" - codex_home.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - existing = { - "tokens": { - "access_token": "[redacted:secret-assignment]", - "refresh_token": "[redacted:secret-assignment]", - "extra_field": "preserved", - }, - "last_refresh": "2026-01-01T00:00:00Z", - "custom_key": "keep_me", - } - (codex_home / "auth.json").write_text(json.dumps(existing)) - - _write_codex_cli_tokens("updated-access", "updated-refresh") - - data = json.loads((codex_home / "auth.json").read_text()) - assert data["tokens"]["access_token"] == "updated-access" - assert data["tokens"]["refresh_token"] == "updated-refresh" - assert data["tokens"]["extra_field"] == "preserved" - assert data["custom_key"] == "keep_me" - # last_refresh not updated since we didn't pass it - assert data["last_refresh"] == "2026-01-01T00:00:00Z" - - -def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch): - """_write_codex_cli_tokens creates parent directories if missing.""" - codex_home = tmp_path / "does" / "not" / "exist" - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - _write_codex_cli_tokens("at", "rt") - - assert (codex_home / "auth.json").exists() - data = json.loads((codex_home / "auth.json").read_text()) - assert data["tokens"]["access_token"] == "at" - - -def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch): - """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.""" - from hermes_cli.auth import _refresh_codex_auth_tokens - - hermes_home = tmp_path / "hermes" - codex_home = tmp_path / "codex-cli" - hermes_home.mkdir(parents=True, exist_ok=True) - codex_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("CODEX_HOME", str(codex_home)) - - # Write initial CLI tokens - (codex_home / "auth.json").write_text(json.dumps({ - "tokens": {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"}, - })) - - # Mock the pure refresh to return new tokens - monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { - "access_token": "[redacted:secret-assignment]", - "refresh_token": "[redacted:secret-assignment]", - "last_refresh": "2026-04-12T01:00:00Z", - }) - - _refresh_codex_auth_tokens( - {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"}, - timeout_seconds=10, - ) - - # Verify CLI file was updated - cli_data = json.loads((codex_home / "auth.json").read_text()) - assert cli_data["tokens"]["access_token"] == "refreshed-at" - assert cli_data["tokens"]["refresh_token"] == "refreshed-rt" - - def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_hermes_auth(hermes_home)
-
-
#15104 fix(codex): consolidated OAuth error parsing + failed-status fallback routing + reauth UX
files:
cli.py,hermes_cli/auth.py,hermes_cli/main.py,run_agent.py,scripts/release.py,tests/hermes_cli/test_auth_codex_provider.py,tests/hermes_cli/test_codex_models.pyShow the diff
-
cli.py@@ -3083,6 +3083,8 @@ def _ensure_runtime_credentials(self) -> bool: format_runtime_provider_error, ) + _primary_exc = None + runtime = None try: runtime = resolve_runtime_provider( requested=self.requested_provider, @@ -3090,7 +3092,34 @@ def _ensure_runtime_credentials(self) -> bool: explicit_base_url=self._explicit_base_url, ) except Exception as exc: - message = format_runtime_provider_error(exc) + _primary_exc = exc + + # Primary provider auth failed — try fallback providers before giving up. + if runtime is None and _primary_exc is not None: + from hermes_cli.auth import AuthError + if isinstance(_primary_exc, AuthError): + _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else [] + for _fb in _fb_chain: + _fb_provider = (_fb.get("provider") or "").strip().lower() + _fb_model = (_fb.get("model") or "").strip() + if not _fb_provider or not _fb_model: + continue + try: + runtime = resolve_runtime_provider(requested=_fb_provider) + logger.warning( + "Primary provider auth failed (%s). Falling through to fallback: %s/%s", + _primary_exc, _fb_provider, _fb_model, + ) + _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}") + self.requested_provider = _fb_provider + self.model = _fb_model + _primary_exc = None + break + except Exception: + continue + + if runtime is None: + message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed." ChatConsole().print(f"[bold red]{message}[/]") return False -
hermes_cli/auth.py@@ -1547,12 +1547,21 @@ def refresh_codex_oauth_pure( try: err = response.json() if isinstance(err, dict): - err_code = err.get("error") - if isinstance(err_code, str) and err_code.strip(): - code = err_code.strip() - err_desc = err.get("error_description") or err.get("message") - if isinstance(err_desc, str) and err_desc.strip(): - message = f"Codex token refresh failed: {err_desc.strip()}" + err_obj = err.get("error") + # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} + if isinstance(err_obj, dict): + nested_code = err_obj.get("code") or err_obj.get("type") + if isinstance(nested_code, str) and nested_code.strip(): + code = nested_code.strip() + nested_msg = err_obj.get("message") + if isinstance(nested_msg, str) and nested_msg.strip(): + message = f"Codex token refresh failed: {nested_msg.strip()}" + # OAuth spec shape: {"error": "code_str", "error_description": "..."} + elif isinstance(err_obj, str) and err_obj.strip(): + code = err_obj.strip() + err_desc = err.get("error_description") or err.get("message") + if isinstance(err_desc, str) and err_desc.strip(): + message = f"Codex token refresh failed: {err_desc.strip()}" except Exception: pass if code in {"invalid_grant", "invalid_token", "invalid_request"}: @@ -3088,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.") + 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 + 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: - reuse = input("Use existing credentials? [Y/n]: ").strip().lower() + do_import = input("Import these credentials? (a separate login is recommended) [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)) + 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("Login successful!") + 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 - 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 # Run a fresh device code flow — Hermes gets its own OAuth session print() -
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: -
run_agent.py@@ -9532,28 +9532,47 @@ def _stop_spinner(): response_invalid = True error_details.append("response is None") else: - # output_text fallback: stream backfill may have failed - # but normalize can still recover from output_text - _out_text = getattr(response, "output_text", None) - _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else "" - if _out_text_stripped: - logger.debug( - "Codex response.output is empty but output_text is present " - "(%d chars); deferring to normalization.", - len(_out_text_stripped), + # Provider returned a terminal failure (e.g. quota exhaustion). + # Treat as invalid so the fallback chain is triggered instead of + # letting the error bubble up outside the retry/fallback loop. + _codex_resp_status = str(getattr(response, "status", "") or "").strip().lower() + if _codex_resp_status in {"failed", "cancelled"}: + _codex_error_obj = getattr(response, "error", None) + _codex_error_msg = ( + _codex_error_obj.get("message") if isinstance(_codex_error_obj, dict) + else str(_codex_error_obj) if _codex_error_obj + else f"Responses API returned status '{_codex_resp_status}'" ) - else: - _resp_status = getattr(response, "status", None) - _resp_incomplete = getattr(response, "incomplete_details", None) - logger.warning( - "Codex response.output is empty after stream backfill " - "(status=%s, incomplete_details=%s, model=%s). %s", - _resp_status, _resp_incomplete, - getattr(response, "model", None), - f"api_mode={self.api_mode} provider={self.provider}", + logging.warning( + "Codex response status='%s' (error=%s). Routing to fallback. %s", + _codex_resp_status, _codex_error_msg, + self._client_log_context(), ) response_invalid = True - error_details.append("response.output is empty") + error_details.append(f"response.status={_codex_resp_status}: {_codex_error_msg}") + else: + # output_text fallback: stream backfill may have failed + # but normalize can still recover from output_text + _out_text = getattr(response, "output_text", None) + _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else "" + if _out_text_stripped: + logger.debug( + "Codex response.output is empty but output_text is present " + "(%d chars); deferring to normalization.", + len(_out_text_stripped), + ) + else: + _resp_status = getattr(response, "status", None) + _resp_incomplete = getattr(response, "incomplete_details", None) + logger.warning( + "Codex response.output is empty after stream backfill " + "(status=%s, incomplete_details=%s, model=%s). %s", + _resp_status, _resp_incomplete, + getattr(response, "model", None), + f"api_mode={self.api_mode} provider={self.provider}", + ) + response_invalid = True + error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": _tv = self._get_transport() if not _tv.validate_response(response): -
scripts/release.py@@ -171,6 +171,8 @@ "[redacted:email]": "satelerd", "[redacted:email]": "danklynn", "[redacted:email]": "MattMaximo", + "[email protected]": "j3ffffff", + "[email protected]": "A-FdL-Prog", "[redacted:email]": "nummanali", "[redacted:email]": "whitehatjr1001", "[email protected]": "0xNyk", -
tests/hermes_cli/test_auth_codex_provider.py@@ -4,6 +4,7 @@ import time import base64 from pathlib import Path +from types import SimpleNamespace import pytest import yaml @@ -15,8 +16,10 @@ _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, resolve_codex_runtime_credentials, resolve_provider, ) @@ -190,3 +193,161 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): assert creds["source"] == "hermes-auth-store" assert creds["provider"] == "openai-codex" assert creds["base_url"] == DEFAULT_CODEX_BASE_URL + + +class _StubHTTPResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload) + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _StubHTTPClient: + def __init__(self, response): + self._response = response + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, *args, **kwargs): + return self._response + + +def _patch_httpx(monkeypatch, response): + def _factory(*args, **kwargs): + return _StubHTTPClient(response) + + monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory) + + +def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch): + """OpenAI returns {"error": {"code": "refresh_token_reused", "message": "..."}} + — parser must surface relogin_required and the dedicated message. + """ + response = _StubHTTPResponse( + 401, + { + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": None, + "code": "refresh_token_reused", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "refresh_token_reused" + assert err.relogin_required is True + # The existing dedicated branch should override the message with actionable guidance. + assert "already consumed by another client" in str(err) + + +def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch): + """Nested error with arbitrary code still surfaces code + message.""" + response = _StubHTTPResponse( + 400, + { + "error": { + "message": "Invalid client credentials.", + "type": "invalid_request_error", + "code": "invalid_client", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "invalid_client" + assert "Invalid client credentials." in str(err) + + +def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch): + """Fallback path: OAuth spec-shape {"error": "invalid_grant", "error_description": "..."} + must still map to relogin_required=True via the existing code set. + """ + response = _StubHTTPResponse( + 400, + { + "error": "invalid_grant", + "error_description": "Refresh token is expired or revoked.", + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "invalid_grant" + assert err.relogin_required is True + assert "Refresh token is expired or revoked." in str(err) + + +def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): + """No JSON body → generic 'with status 401' message; 401 always forces relogin.""" + response = _StubHTTPResponse(401, ValueError("not json")) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "codex_refresh_failed" + # 401/403 from the token endpoint always means the refresh token is + # 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": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"}, + ) + monkeypatch.setattr( + "hermes_cli.auth._codex_device_code_login", + lambda: { + "tokens": {"access_token": "[redacted:secret-assignment]", "refresh_token": "[redacted:secret-assignment]"}, + "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" -
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 _fake_prompt_model_selection(model_ids, current_model=""): 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": "[redacted:secret-assignment]"}, + ) + + 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": "[redacted:secret-assignment]"}, + ) + + def _fake_get_codex_model_ids(access_token=[redacted:secret-assignment] + 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 ──────────────────────────
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#6652 fix(codex): recover stale CLI auth sync state · superseded by another PR →
#12360,#15104
Closed issues · 1
- Closed#6651[Bug]: Codex CLI sync does not recover stale Hermes auth state for gateway/auxiliary flows
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab