The fold routed every aux Codex read through _resolve_codex_credential_and_base, leaving _read_codex_access_token with no production callers; three test patches on it had gone inert (including the 'should use pool token' guard). Point them at the live seams instead.
1129 lines
58 KiB
Python
1129 lines
58 KiB
Python
"""OpenAI Codex OAuth: token store, refresh, quota probe, device-code login.
|
|
|
|
Tokens live in ~/.hermes/auth.json, NOT ~/.codex/: Hermes keeps its own Codex OAuth session
|
|
separate from the Codex CLI / VS Code extension so one app's refresh-token rotation cannot
|
|
invalidate the other's session.
|
|
|
|
Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily inside each function
|
|
so ``hermes_cli.auth.<name>`` patches still intercept (and no import cycle).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
|
|
from hermes_cli.auth_constants import (
|
|
_decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError,
|
|
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL,
|
|
CODEX_OAUTH_USER_AGENT, CODEX_RATE_LIMITED_CODE, DEFAULT_CODEX_BASE_URL, _codex_err, httpx)
|
|
from utils import env_float
|
|
|
|
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
|
|
from hermes_cli.auth import ProviderConfig
|
|
|
|
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
|
|
logger = logging.getLogger("hermes_cli.auth")
|
|
|
|
# ``{relogin}`` is filled at raise time with the profile-aware sign-in command: a bare
|
|
# ``hermes auth`` from a named profile re-signs the ROOT store (93889b770da, #114012).
|
|
_MISSING_ACCESS_TOKEN_MSG = "Codex auth is missing access_token. Run `{relogin}` to re-authenticate."
|
|
_MISSING_REFRESH_TOKEN_MSG = "Codex auth is missing refresh_token. Run `{relogin}` to re-authenticate."
|
|
_NO_CREDENTIALS_MSG = "No Codex credentials stored. Run `{relogin}` to authenticate."
|
|
|
|
|
|
def _codex_relogin_command() -> str:
|
|
from agent.turn_failure_copy import oauth_relogin_command
|
|
|
|
return oauth_relogin_command("openai-codex")
|
|
|
|
|
|
def _parse_retry_after_seconds(headers: Any) -> Optional[int]:
|
|
"""Best-effort parse of a ``Retry-After`` header into whole seconds."""
|
|
from agent.retry_utils import parse_retry_after_seconds
|
|
seconds = parse_retry_after_seconds(headers)
|
|
return None if seconds is None else int(seconds)
|
|
|
|
|
|
def _stripped(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _clear_pool_entry_status(entry: Dict[str, Any]) -> None:
|
|
"""Reset a pool entry's cooldown / last-error metadata to healthy."""
|
|
from hermes_cli.auth import _POOL_STATUS_FIELDS
|
|
for status_field in _POOL_STATUS_FIELDS:
|
|
entry[status_field] = None
|
|
|
|
|
|
def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool:
|
|
exp = _decode_jwt_claims(access_token).get("exp")
|
|
return isinstance(exp, (int, float)) and float(exp) <= (time.time() + max(0, int(skew_seconds)))
|
|
|
|
|
|
def _codex_base_url() -> str:
|
|
return os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL
|
|
|
|
|
|
def _codex_pool_route_base_url(entry_base_url: Optional[str] = "") -> str:
|
|
"""Base URL the chat route sends a pooled Codex credential to — the same rule
|
|
``runtime_provider._pool_entry_mode_and_url`` applies (``HERMES_CODEX_BASE_URL`` > ``model.base_url``
|
|
while the row still carries the canonical URL > the row's own URL). A pooled gateway key belongs to
|
|
that host only; composing it with the ambient default sends it to chatgpt.com (#121486)."""
|
|
base = _stripped(entry_base_url).rstrip("/")
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
from hermes_cli.runtime_provider import _pool_entry_mode_and_url
|
|
model_cfg = load_config_readonly().get("model")
|
|
return _pool_entry_mode_and_url(
|
|
"openai-codex", None, model_cfg if isinstance(model_cfg, dict) else {}, "", base)[1]
|
|
except Exception:
|
|
logger.debug("Codex pool route base resolution failed", exc_info=True)
|
|
# Profile-scoped override only (never the raw process env: a multiplexed sibling's gateway).
|
|
with suppress(Exception):
|
|
from agent.secret_scope import get_secret_str
|
|
base = _stripped(get_secret_str("HERMES_CODEX_BASE_URL", "")).rstrip("/") or base
|
|
return base or DEFAULT_CODEX_BASE_URL
|
|
|
|
|
|
def _codex_runtime_result(
|
|
api_key: str, *, source: str, last_refresh: Optional[str], base_url: Optional[str] = None) -> Dict[str, Any]:
|
|
return {
|
|
"provider": "openai-codex", "base_url": base_url or _codex_base_url(), "api_key": api_key,
|
|
"source": source, "last_refresh": last_refresh, "auth_mode": "chatgpt"}
|
|
|
|
|
|
def _load_auth_store_maybe_locked(lock: bool) -> Dict[str, Any]:
|
|
"""Load the auth store, taking the cross-process lock unless the caller already holds it."""
|
|
from hermes_cli.auth import _auth_store_lock, _load_auth_store
|
|
if lock:
|
|
with _auth_store_lock():
|
|
return _load_auth_store()
|
|
return _load_auth_store()
|
|
|
|
|
|
def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
|
"""Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json)."""
|
|
from hermes_cli.auth import _load_provider_state, _nonempty_str
|
|
auth_store = _load_auth_store_maybe_locked(_lock)
|
|
state = _load_provider_state(auth_store, "openai-codex")
|
|
if not state:
|
|
raise _codex_err(_NO_CREDENTIALS_MSG.format(relogin=_codex_relogin_command()),
|
|
"codex_auth_missing", relogin=True)
|
|
tokens = state.get("tokens")
|
|
if not isinstance(tokens, dict):
|
|
raise _codex_err(
|
|
f"Codex auth state is missing tokens. Run `{_codex_relogin_command()}` to re-authenticate.",
|
|
"codex_auth_invalid_shape", relogin=True)
|
|
if not _nonempty_str(tokens.get("access_token")):
|
|
raise _codex_err(_MISSING_ACCESS_TOKEN_MSG.format(relogin=_codex_relogin_command()),
|
|
"codex_auth_missing_access_token", relogin=True)
|
|
if not _nonempty_str(tokens.get("refresh_token")):
|
|
raise _codex_err(_MISSING_REFRESH_TOKEN_MSG.format(relogin=_codex_relogin_command()),
|
|
"codex_auth_missing_refresh_token", relogin=True)
|
|
return {"tokens": tokens, "last_refresh": state.get("last_refresh")}
|
|
|
|
|
|
def _sync_codex_pool_entries(
|
|
auth_store: Dict[str, Any], tokens: Dict[str, str], last_refresh: Optional[str],
|
|
previous_singleton_tokens: Optional[Dict[str, str]] = None) -> None:
|
|
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
|
|
|
|
``device_code`` (the singleton-seeded entry from ``hermes setup`` / the model picker) is always
|
|
synced. ``manual:device_code`` (``hermes auth add openai-codex``) is synced only when its
|
|
access_token equals the PREVIOUS singleton token — a legacy alias of the singleton; an entry
|
|
with its own token material is an independent account and must be left alone. ``manual:api_key``
|
|
and any other source are independent credentials and are never overwritten by a re-auth.
|
|
|
|
See #33000, #39236.
|
|
The original #33538 fix refreshed every ``manual:device_code`` entry unconditionally. That worked when
|
|
``manual:device_code`` only meant "legacy alias of the singleton", but the same source string is now
|
|
also produced by independent-account additions, and the broad sync silently clobbered distinct accounts
|
|
with the latest-authenticated token pair. The access_token-match check distinguishes the two cases
|
|
without changing the source-string contract.
|
|
"""
|
|
access_token = tokens.get("access_token")
|
|
if not access_token:
|
|
return
|
|
refresh_token = tokens.get("refresh_token")
|
|
entries = _pool_entries(auth_store, "openai-codex")
|
|
if entries is None:
|
|
return
|
|
# None/empty prev_at → no manual entry can be an alias (right default for a first-ever save).
|
|
prev_at = (previous_singleton_tokens or {}).get("access_token") or None
|
|
for entry in _codex_pool_dicts(entries):
|
|
source = entry.get("source")
|
|
is_alias = source == "manual:device_code" and bool(
|
|
prev_at and entry.get("access_token") == prev_at)
|
|
if not (source == "device_code" or is_alias):
|
|
continue
|
|
entry["access_token"] = access_token
|
|
if refresh_token:
|
|
entry["refresh_token"] = refresh_token
|
|
if last_refresh:
|
|
entry["last_refresh"] = last_refresh
|
|
_clear_pool_entry_status(entry)
|
|
|
|
|
|
def _save_codex_tokens(
|
|
tokens: Dict[str, str], last_refresh: str = None, label: str = None, *,
|
|
set_active: bool = True, write_through: bool = False,
|
|
) -> None:
|
|
"""Save Codex OAuth tokens to the auth store the grant was resolved FROM.
|
|
|
|
Codex refresh tokens are single-use with rotation-family reuse detection. A profile without its
|
|
own ``providers.openai-codex`` block reads root's grant via the fallback, so a refresh under that
|
|
profile must rotate ROOT's chain — singleton AND ``credential_pool`` entries — or root keeps the
|
|
consumed refresh token, the next process replays it and OpenAI revokes the whole family
|
|
(#87503). Root-only write-back: a profile copy would shadow root and disable the write-through
|
|
on the next refresh (#74339). Mirrors the xAI source-aware save.
|
|
|
|
Only a token REFRESH passes ``write_through=True``: a fresh login or import under a profile
|
|
is the profile's own grant and must not overwrite the root account it was borrowing.
|
|
``set_active=False`` stores credentials for a side tool (image gen) without making Codex the
|
|
active inference provider.
|
|
"""
|
|
from hermes_cli.auth import (
|
|
_auth_file_path, _load_auth_store, _provider_state_transaction, _same_path,
|
|
_save_auth_store, _store_provider_state, _utc_now_z)
|
|
if last_refresh is None:
|
|
last_refresh = _utc_now_z()
|
|
with _provider_state_transaction("openai-codex") as (auth_store, state, source_path):
|
|
state = dict(state) if state else {}
|
|
# Capture the previous singleton tokens BEFORE overwriting: the pool sync uses them to
|
|
# tell legacy singleton-aliases (refresh) from independent ``auth add`` accounts (keep).
|
|
previous_singleton_tokens = (
|
|
state.get("tokens") if isinstance(state.get("tokens"), dict) else None)
|
|
state.update(tokens=tokens, last_refresh=last_refresh, auth_mode="chatgpt")
|
|
if label and str(label).strip():
|
|
state["label"] = str(label).strip()
|
|
target_store, target_path = auth_store, None
|
|
if write_through and source_path is not None and not _same_path(source_path, _auth_file_path()):
|
|
# Root-borrowed grant: the transaction already holds root's lock, so write the rotated
|
|
# chain into ROOT's store (never set_active — a refresh is not a provider choice).
|
|
target_store, target_path, set_active = _load_auth_store(source_path), source_path, False
|
|
_store_provider_state(target_store, "openai-codex", state, set_active=set_active)
|
|
_sync_codex_pool_entries(
|
|
target_store, tokens, last_refresh, previous_singleton_tokens=previous_singleton_tokens)
|
|
_save_auth_store(target_store, target_path=target_path)
|
|
|
|
|
|
def _recover_codex_tokens_from_cli(
|
|
reason: str, observed_access_token: Optional[str] = None) -> Optional[Dict[str, str]]:
|
|
"""Adopt a valid Codex CLI token pair into Hermes auth, if available.
|
|
|
|
Automatic adoption only; the interactive import offer in ``_login_openai_codex`` asks first and is
|
|
not subject to ``auth.adopt_external_logins``.
|
|
|
|
``observed_access_token`` is the singleton access_token (or None) the caller saw when it decided
|
|
the credential needs repair. Recovery repairs THAT credential and nothing else (#73667): a Codex
|
|
Desktop/CLI login into another ChatGPT workspace must not replace it silently, and a concurrent
|
|
explicit re-auth must not be overwritten, so the save is a compare-and-swap under the store lock.
|
|
"""
|
|
from agent.credential_pool import _codex_principal_identity
|
|
from agent.credential_sources import adopt_external_logins_enabled
|
|
from hermes_cli.auth import _import_codex_cli_tokens, _provider_state_transaction, _save_codex_tokens
|
|
if not adopt_external_logins_enabled():
|
|
return None
|
|
imported = _import_codex_cli_tokens()
|
|
# Require BOTH tokens before adopting: persisting a payload without a usable refresh_token
|
|
# would only break the next refresh cycle.
|
|
if not (imported and _stripped(imported.get("access_token"))
|
|
and _stripped(imported.get("refresh_token"))):
|
|
return None
|
|
observed = _stripped(observed_access_token) or None
|
|
with _provider_state_transaction("openai-codex") as (_store, state, _source):
|
|
stored = (state or {}).get("tokens")
|
|
stored = stored if isinstance(stored, dict) else {}
|
|
if (_stripped(stored.get("access_token")) or None) != observed:
|
|
logger.info("Codex CLI recovery skipped (%s): the credential was re-authenticated meanwhile.", reason)
|
|
return None
|
|
known = _codex_principal_identity(observed)
|
|
if known and _codex_principal_identity(imported["access_token"]) not in (None, known):
|
|
logger.warning(
|
|
"Codex CLI recovery refused (%s): the Codex CLI login belongs to a different ChatGPT "
|
|
"workspace than the Hermes credential. Run `%s` to re-authenticate it.",
|
|
reason, _codex_relogin_command())
|
|
return None
|
|
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
|
|
_save_codex_tokens(imported) # nested: the per-path lock is reentrant
|
|
return dict(imported)
|
|
|
|
|
|
def _refresh_payload_access_token(
|
|
response: "httpx.Response", *, provider: str, invalid_json: Tuple[str, str],
|
|
invalid_response: Optional[Tuple[str, str]], missing_access: Tuple[str, str],
|
|
relogin_required: bool = True, invalid_json_relogin: Optional[bool] = None,
|
|
strict_str: bool = True) -> Tuple[Dict[str, Any], str]:
|
|
"""Parse a 200 token-refresh response; return ``(payload, stripped access_token)``.
|
|
|
|
Each ``(message, code)`` pair keeps the provider's historical wording; ``{exc}`` in
|
|
*invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string
|
|
access tokens; otherwise they are ``str()``-coerced.
|
|
"""
|
|
def _err(message: str, code: str, relogin: bool = relogin_required) -> AuthError:
|
|
return AuthError(message, provider=provider, code=code, relogin_required=relogin)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except Exception as exc:
|
|
relogin = relogin_required if invalid_json_relogin is None else invalid_json_relogin
|
|
raise _err(invalid_json[0].format(exc=exc), invalid_json[1], relogin) from exc
|
|
if not isinstance(payload, dict):
|
|
if invalid_response is not None:
|
|
raise _err(*invalid_response)
|
|
payload = {}
|
|
access = payload.get("access_token")
|
|
if strict_str:
|
|
access = access.strip() if isinstance(access, str) else ""
|
|
else:
|
|
access = _stripped(access)
|
|
if not access:
|
|
raise _err(*missing_access)
|
|
return payload, access
|
|
|
|
|
|
_SSL_TROUBLE_MARKERS = ("[SSL:", "_ssl.c", "UNEXPECTED_EOF")
|
|
|
|
|
|
def _ssl_interop_hint(exc: BaseException) -> str:
|
|
"""Actionable hint for device-login transport errors that look like TLS middlebox interference.
|
|
|
|
OpenSSL 3.5+ advertises post-quantum hybrid groups (e.g. X25519MLKEM768) by default, and some
|
|
intercepting middleboxes reject the resulting larger TLS 1.3 ClientHello — while curl, using a
|
|
different TLS stack, still works, so the failure masquerades as a Codex outage (#106384).
|
|
httpx wraps the ``ssl.SSLError`` in a ``ConnectError``/``ConnectTimeout`` whose text usually
|
|
repeats the OpenSSL message; the cause chain is checked too in case it doesn't.
|
|
"""
|
|
import ssl
|
|
|
|
chain = (exc, exc.__cause__, exc.__context__)
|
|
if not any(
|
|
isinstance(err, ssl.SSLError) or any(marker in str(err) for marker in _SSL_TROUBLE_MARKERS)
|
|
for err in chain if err is not None
|
|
):
|
|
return ""
|
|
return (
|
|
" This looks like a TLS handshake failure rather than a Codex outage: some networks reject"
|
|
" the larger TLS 1.3 ClientHello that OpenSSL 3.5+ sends by default (post-quantum hybrid"
|
|
" groups). Workaround: point OPENSSL_CONF at a config restricting Groups to classic curves"
|
|
" (x25519:secp256r1:secp384r1:x448), or test with TLS 1.2 — see the Codex note in"
|
|
" https://hermes-agent.nousresearch.com/docs/integrations/providers"
|
|
)
|
|
|
|
|
|
def _is_transient_transport_error(exc: BaseException) -> bool:
|
|
"""True when *exc* is transport-level (connection/TLS/socket) and safe to retry.
|
|
|
|
httpx raises ``httpx.TransportError`` subclasses wrapping the original ``ssl``/``socket``
|
|
error as ``__cause__``, so both spellings count. Anything else (decode errors, bugs) is
|
|
not a network blip and must surface immediately.
|
|
"""
|
|
err: Optional[BaseException] = exc
|
|
seen: set[int] = set()
|
|
while err is not None and id(err) not in seen:
|
|
if isinstance(err, (httpx.TransportError, OSError)):
|
|
return True
|
|
seen.add(id(err))
|
|
err = err.__cause__
|
|
return False
|
|
|
|
|
|
def _codex_login_post(url: str, *, failure: Tuple[str, str], **kwargs: Any) -> "httpx.Response":
|
|
"""One 15s POST for the device-login flow; transport errors become ``_codex_err(*failure)``.
|
|
|
|
A transient transport blip (a dropped connection mid-flow) is retried twice with a small
|
|
linear backoff before failing: losing the token exchange to a single SSL EOF wastes a
|
|
device-code approval the user already completed in the browser (#114610).
|
|
"""
|
|
attempt, attempts = 1, 3
|
|
while True:
|
|
try:
|
|
with _codex_http_client(timeout=httpx.Timeout(15.0)) as client:
|
|
return client.post(url, **kwargs)
|
|
except Exception as exc:
|
|
if attempt == attempts or not _is_transient_transport_error(exc):
|
|
raise _codex_err(
|
|
f"{failure[0]}: {exc}{_ssl_interop_hint(exc)}", failure[1]) from exc
|
|
time.sleep(attempt)
|
|
attempt += 1
|
|
|
|
|
|
_CODEX_AUTH_BODY_MAX_BYTES = 1024 * 1024 # real OAuth/device-auth payloads are a few hundred bytes
|
|
|
|
|
|
def _capped_byte_stream_class() -> type:
|
|
"""Build the capped stream subclass on first use.
|
|
|
|
The base class is ``httpx.SyncByteStream``; naming it at module scope would resolve the
|
|
lazy ``httpx`` proxy at import time and put httpx back on the interactive-CLI startup path
|
|
(see ``auth_constants``).
|
|
"""
|
|
cached = getattr(_capped_byte_stream_class, "cls", None)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
class _CappedByteStream(httpx.SyncByteStream):
|
|
"""Body stream that raises once more than ``_CODEX_AUTH_BODY_MAX_BYTES`` came off the wire.
|
|
|
|
httpx type-checks ``response.stream`` against ``SyncByteStream``, so the cap has to be a
|
|
stream subclass rather than a bare generator.
|
|
"""
|
|
|
|
def __init__(self, response: "httpx.Response") -> None:
|
|
self._response, self._raw = response, response.stream
|
|
|
|
def __iter__(self) -> Iterator[bytes]:
|
|
total = 0
|
|
for chunk in self._raw: # type: ignore[union-attr] # sync client only
|
|
total += len(chunk)
|
|
if total > _CODEX_AUTH_BODY_MAX_BYTES:
|
|
self.close()
|
|
raise _codex_err(
|
|
f"Codex auth response from {self._response.url.host} exceeded "
|
|
f"{_CODEX_AUTH_BODY_MAX_BYTES // 1024} KiB; refusing to parse it.",
|
|
"codex_auth_response_too_large", relogin=False)
|
|
yield chunk
|
|
|
|
def close(self) -> None:
|
|
self._raw.close() # type: ignore[union-attr]
|
|
|
|
_capped_byte_stream_class.cls = _CappedByteStream # type: ignore[attr-defined]
|
|
return _CappedByteStream
|
|
|
|
|
|
def _cap_codex_response_body(response: "httpx.Response") -> None:
|
|
"""httpx response hook: refuse to buffer an auth body above ``_CODEX_AUTH_BODY_MAX_BYTES``.
|
|
|
|
Runs before ``client.post()`` reads the body, so a hostile or broken endpoint/proxy answering
|
|
200 with megabytes of "JSON" is cut off at the cap instead of being fully buffered and parsed
|
|
(#55253). Same cap for every status: error bodies are small diagnostics too.
|
|
"""
|
|
response.stream = _capped_byte_stream_class()(response)
|
|
|
|
|
|
def _codex_http_client(**kwargs: Any) -> "httpx.Client":
|
|
"""Build an ``httpx.Client`` for Codex OAuth/probe endpoints with Happy-Eyeballs racing and a
|
|
1 MiB response-body cap (``_cap_codex_response_body``).
|
|
|
|
A host advertising AAAA records but blackholing IPv6 makes each serial connect eat the full
|
|
timeout before IPv4 is tried (same failure mode as the chat transport). Best-effort: if the
|
|
racing backend can't be installed (mocked client in tests), serial connect behavior remains.
|
|
|
|
Same broken-IPv6 failure mode as the chat transport (#13834): a host that advertises AAAA records but
|
|
blackholes IPv6 makes each serial connect attempt eat the full connect timeout before IPv4 is tried, so
|
|
token refresh / device login / usage probes time out where the official Codex CLI (which races families
|
|
per RFC 8305) works.
|
|
"""
|
|
client = httpx.Client(event_hooks={"response": [_cap_codex_response_body]}, **kwargs)
|
|
with suppress(Exception):
|
|
from agent.process_bootstrap import enable_happy_eyeballs_on_client
|
|
enable_happy_eyeballs_on_client(client)
|
|
return client
|
|
|
|
|
|
def _codex_quota_exhausted_error(retry_after: Optional[int]) -> AuthError:
|
|
message = (
|
|
f"Codex provider quota exhausted (429); retry after {retry_after}s. "
|
|
"Credentials are still valid."
|
|
if retry_after is not None else
|
|
"Codex provider quota exhausted (429). Credentials are still valid; "
|
|
"retry after the usage limit resets.")
|
|
return _codex_err(message, CODEX_RATE_LIMITED_CODE, relogin=False)
|
|
|
|
|
|
def _codex_refresh_failure_error(response: "httpx.Response") -> AuthError:
|
|
"""Decode a non-200 Codex token-refresh response into a shaped AuthError."""
|
|
from hermes_cli.auth import _nonempty_str
|
|
code = "codex_refresh_failed"
|
|
message = f"Codex token refresh failed with status {response.status_code}."
|
|
try:
|
|
err = response.json()
|
|
if isinstance(err, dict):
|
|
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 _nonempty_str(nested_code):
|
|
code = nested_code.strip()
|
|
nested_msg = err_obj.get("message")
|
|
if _nonempty_str(nested_msg):
|
|
message = f"Codex token refresh failed: {nested_msg.strip()}"
|
|
# OAuth spec shape: {"error": "code_str", "error_description": "..."}
|
|
elif _nonempty_str(err_obj):
|
|
code = err_obj.strip()
|
|
err_desc = err.get("error_description") or err.get("message")
|
|
if _nonempty_str(err_desc):
|
|
message = f"Codex token refresh failed: {err_desc.strip()}"
|
|
except Exception:
|
|
pass
|
|
if code == "refresh_token_reused":
|
|
message = (
|
|
"Codex refresh token was already consumed by another client "
|
|
"(e.g. Codex CLI or VS Code extension). "
|
|
"Run `codex` in your terminal to generate fresh tokens, "
|
|
f"then run `{_codex_relogin_command()}` to re-authenticate.")
|
|
# A 401/403 from the token endpoint always means the refresh token is invalid/expired —
|
|
# force relogin even if the body error code wasn't one of the known strings.
|
|
relogin_required = (
|
|
code in {"invalid_grant", "invalid_token", "invalid_request", "refresh_token_reused"}
|
|
or response.status_code in {401, 403})
|
|
return _codex_err(message, code, relogin=relogin_required)
|
|
|
|
|
|
def refresh_codex_oauth_pure(
|
|
access_token: str, refresh_token: str, *, timeout_seconds: float = 20.0) -> Dict[str, Any]:
|
|
"""Refresh Codex OAuth tokens without mutating Hermes auth state."""
|
|
from hermes_cli.auth import _nonempty_str, _utc_now_z
|
|
del access_token # Access token is only used by callers to decide whether to refresh.
|
|
if not _nonempty_str(refresh_token):
|
|
raise _codex_err(_MISSING_REFRESH_TOKEN_MSG.format(relogin=_codex_relogin_command()),
|
|
"codex_auth_missing_refresh_token", relogin=True)
|
|
with _codex_http_client(
|
|
timeout=httpx.Timeout(max(5.0, float(timeout_seconds))),
|
|
headers={"Accept": "application/json", "User-Agent": CODEX_OAUTH_USER_AGENT}) as client:
|
|
response = client.post(
|
|
CODEX_OAUTH_TOKEN_URL, headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
data={
|
|
"grant_type": "refresh_token", "refresh_token": refresh_token,
|
|
"client_id": CODEX_OAUTH_CLIENT_ID})
|
|
if response.status_code == 429:
|
|
# Quota exhaustion on the token endpoint: the refresh token is still valid and re-auth
|
|
# cannot lift a quota cap, so classify distinctly from auth failures ("retry later").
|
|
raise _codex_quota_exhausted_error(
|
|
_parse_retry_after_seconds(getattr(response, "headers", None)))
|
|
if response.status_code != 200:
|
|
raise _codex_refresh_failure_error(response)
|
|
refresh_payload, refreshed_access = _refresh_payload_access_token(
|
|
response, provider="openai-codex", invalid_response=None,
|
|
invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"),
|
|
missing_access=(
|
|
"Codex token refresh response was missing access_token.",
|
|
"codex_refresh_missing_access_token"))
|
|
updated = {
|
|
"access_token": refreshed_access, "refresh_token": refresh_token.strip(),
|
|
"last_refresh": _utc_now_z()}
|
|
next_refresh = refresh_payload.get("refresh_token")
|
|
if _nonempty_str(next_refresh):
|
|
updated["refresh_token"] = next_refresh.strip()
|
|
return updated
|
|
|
|
|
|
def _refresh_codex_auth_tokens(tokens: Dict[str, str], timeout_seconds: float) -> Dict[str, str]:
|
|
"""Refresh Codex access token using the refresh token.
|
|
|
|
The whole re-read -> endpoint POST -> write-back runs inside the SOURCE store's transaction:
|
|
two profiles borrowing the same root grant otherwise both submit the same single-use refresh
|
|
token (each holds only its own profile lock) and OpenAI revokes the family. A waiter that
|
|
finds root already rotated by its peer adopts the stored pair instead of replaying the
|
|
consumed token. Both locks wait out a full endpoint timeout so the waiter adopts, not times out.
|
|
"""
|
|
from hermes_cli.auth import _provider_state_transaction, _save_codex_tokens, refresh_codex_oauth_pure
|
|
lock_timeout = max(float(AUTH_LOCK_TIMEOUT_SECONDS), float(timeout_seconds) + 5.0)
|
|
with _provider_state_transaction("openai-codex", lock_timeout) as (_store, state, _source):
|
|
stored = (state or {}).get("tokens")
|
|
stored = stored if isinstance(stored, dict) else {}
|
|
stored_at, stored_rt = _stripped(stored.get("access_token")), _stripped(stored.get("refresh_token"))
|
|
if stored_at and stored_rt and stored_rt != _stripped(tokens.get("refresh_token")):
|
|
logger.info("Codex refresh token already rotated by a peer — adopting the stored pair.")
|
|
return {**tokens, "access_token": stored_at, "refresh_token": stored_rt}
|
|
try:
|
|
refreshed = refresh_codex_oauth_pure(
|
|
str(tokens.get("access_token", "") or ""), str(tokens.get("refresh_token", "") or ""),
|
|
timeout_seconds=timeout_seconds)
|
|
except AuthError as exc:
|
|
# Self-heal cross-store rotation: refresh_tokens are single-use, so when the Codex CLI
|
|
# (or another Hermes process) rotates the shared token this frozen copy fails with a
|
|
# relogin-required error (invalid_grant / refresh_token_reused / 401). Adopt the
|
|
# canonical fresh token from ~/.codex/auth.json before surfacing a hard 401. Transient
|
|
# failures (429 quota) keep relogin_required=False — the stored token is still valid —
|
|
# re-raise.
|
|
if not getattr(exc, "relogin_required", False):
|
|
raise
|
|
imported = _recover_codex_tokens_from_cli(
|
|
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}",
|
|
observed_access_token=stored_at or None)
|
|
if not imported:
|
|
raise
|
|
return imported
|
|
updated_tokens = {
|
|
**tokens, "access_token": refreshed["access_token"],
|
|
"refresh_token": refreshed["refresh_token"]}
|
|
# Nested transaction: the per-path lock is reentrant, and it re-reads under the held locks.
|
|
_save_codex_tokens(updated_tokens, write_through=True)
|
|
return updated_tokens
|
|
|
|
|
|
def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
|
|
"""Read ~/.codex/auth.json (Codex CLI file) tokens if valid and not expired; never writes."""
|
|
from hermes_cli.auth import _codex_access_token_is_expiring
|
|
codex_home = os.getenv("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
|
auth_path = Path(codex_home).expanduser() / "auth.json"
|
|
if not auth_path.is_file():
|
|
return None
|
|
try:
|
|
tokens = json.loads(auth_path.read_text(encoding="utf-8-sig")).get("tokens")
|
|
if not (isinstance(tokens, dict) and tokens.get("access_token")
|
|
and tokens.get("refresh_token")):
|
|
return None
|
|
# Importing stale tokens that can't be refreshed would leave the user with
|
|
# "Login successful!" but no working credentials.
|
|
if _codex_access_token_is_expiring(tokens["access_token"], 0):
|
|
logger.debug("Codex CLI tokens at %s are expired — skipping import.", auth_path)
|
|
return None
|
|
return dict(tokens)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def resolve_codex_runtime_credentials(
|
|
*, force_refresh: bool = False, refresh_if_expiring: bool = True,
|
|
refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
|
read_only: bool = False) -> Dict[str, Any]:
|
|
"""Resolve runtime credentials from Hermes's own Codex token store.
|
|
|
|
``read_only=True`` (status / doctor / pickers) reports the stored state as-is: no Codex CLI
|
|
adoption, no token refresh, no auth-store write — and it wins over ``force_refresh``. A
|
|
diagnostic that silently imports another program's rotating refresh token or spends one is a
|
|
mutation the user never asked for (#68004).
|
|
|
|
Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) has no
|
|
usable access_token but the pool (``credential_pool.openai-codex``) does.
|
|
|
|
This closes the divergence between the chat path (singleton-only via this function) and the auxiliary
|
|
path (pool-first via ``auxiliary_client._resolve_codex_credential_and_base``). Without this fallback, a user whose tokens live only
|
|
in the pool — for example after a manual pool seed, a partial re-auth, or pool-only restoration from a
|
|
backup — gets a bare HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
|
|
credential. See issue #32992.
|
|
"""
|
|
from hermes_cli.auth import (
|
|
_auth_store_lock, _codex_access_token_is_expiring, _probe_codex_quota_restored,
|
|
_read_codex_tokens)
|
|
read_error: Optional[AuthError] = None
|
|
data = None
|
|
observed: Optional[str] = None
|
|
try:
|
|
if read_only:
|
|
# A read-only report takes no store lock: ``_save_auth_store`` replaces auth.json
|
|
# atomically, and materialising ``auth.lock`` is itself a write a diagnostic must not
|
|
# make. No recovery follows a read-only read, so no observed token is needed.
|
|
data = _read_codex_tokens(_lock=False)
|
|
else:
|
|
with _auth_store_lock():
|
|
# Observe the singleton in the same locked snapshot the read validates, so recovery
|
|
# can compare-and-swap against exactly the credential it is repairing (#73667).
|
|
from hermes_cli.auth import _load_auth_store, _load_provider_state
|
|
raw = (_load_provider_state(_load_auth_store(), "openai-codex") or {}).get("tokens")
|
|
observed = raw.get("access_token") if isinstance(raw, dict) else None
|
|
data = _read_codex_tokens(_lock=False)
|
|
except AuthError as exc:
|
|
read_error = exc
|
|
if not read_only and exc.relogin_required and exc.code in {
|
|
"codex_auth_missing_access_token", "codex_auth_missing_refresh_token",
|
|
"codex_auth_invalid_shape"}:
|
|
imported = _recover_codex_tokens_from_cli(
|
|
str(exc.code or "auth_error"), observed_access_token=observed)
|
|
if imported:
|
|
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
|
|
if data is None:
|
|
pool_token, pool_base = _pool_codex_credential()
|
|
if pool_token and force_refresh and not read_only:
|
|
# Pool-only setup: a forced refresh must rotate the pool entry, not resend its token.
|
|
from agent.credential_pool import load_pool
|
|
refreshed = load_pool("openai-codex").try_refresh_matching(api_key_hint=pool_token)
|
|
pool_token = refreshed.runtime_api_key if refreshed is not None else ""
|
|
if pool_token:
|
|
# Report the host this row routes to, not the ambient default: a pooled gateway key
|
|
# paired with chatgpt.com leaks to every consumer of this result (#121486).
|
|
return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None,
|
|
base_url=_codex_pool_route_base_url(pool_base))
|
|
pool_rate_limit = _codex_pool_rate_limit_status()
|
|
if pool_rate_limit:
|
|
# Before surfacing the persisted cooldown, ask the usage endpoint whether the quota
|
|
# reset early (banked reset redeemed, plan upgraded): ``last_error_reset_at`` can be
|
|
# days in the future while the account is already usable again. Never from a
|
|
# read-only caller: the picker fingerprint resolves on every cache-only read.
|
|
if not read_only and _probe_codex_pool_entry_quota_restored(pool_rate_limit):
|
|
logger.info("Codex quota restored upstream — clearing stale pool cooldown(s).")
|
|
clear_codex_pool_quota_cooldowns()
|
|
pool_token, pool_base = _pool_codex_credential()
|
|
if pool_token:
|
|
return _codex_runtime_result(
|
|
pool_token, source="credential_pool", last_refresh=None,
|
|
base_url=_codex_pool_route_base_url(pool_base))
|
|
reset_at = pool_rate_limit.get("reset_at")
|
|
in_future = isinstance(reset_at, (int, float)) and reset_at > time.time()
|
|
raise _codex_quota_exhausted_error(int(reset_at - time.time()) if in_future else None)
|
|
if read_error is not None:
|
|
raise read_error
|
|
raise _codex_err(_NO_CREDENTIALS_MSG.format(relogin=_codex_relogin_command()),
|
|
"codex_auth_missing", relogin=True)
|
|
tokens = dict(data["tokens"])
|
|
access_token = _stripped(tokens.get("access_token"))
|
|
refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20)
|
|
|
|
def _should_refresh(token: str) -> bool:
|
|
if read_only:
|
|
return False
|
|
return bool(force_refresh) or (
|
|
refresh_if_expiring and _codex_access_token_is_expiring(token, refresh_skew_seconds))
|
|
|
|
if _should_refresh(access_token):
|
|
# Re-read under lock to avoid racing with other Hermes processes
|
|
lock_timeout = max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)
|
|
with _auth_store_lock(timeout_seconds=lock_timeout):
|
|
data = _read_codex_tokens(_lock=False)
|
|
tokens = dict(data["tokens"])
|
|
if _should_refresh(_stripped(tokens.get("access_token"))):
|
|
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
|
|
access_token = _stripped(tokens.get("access_token"))
|
|
return _codex_runtime_result(
|
|
access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"))
|
|
|
|
|
|
def _is_codex_rate_limit_shaped(code: Any, reason: Any, message: Any) -> bool:
|
|
"""True when persisted pool-entry error metadata describes a 429/quota stop."""
|
|
reason_l, message_l = str(reason or "").lower(), str(message or "").lower()
|
|
return (
|
|
code == 429
|
|
or any(k in reason_l for k in ("rate_limit", "usage_limit", "quota"))
|
|
or any(k in message_l for k in ("rate limit", "usage limit", "quota")))
|
|
|
|
|
|
def _entry_is_rate_limit_exhausted(entry: Dict[str, Any]) -> bool:
|
|
"""Pool entry frozen by a 429/quota stop (as opposed to an auth failure)."""
|
|
return entry.get("last_status") == "exhausted" and _is_codex_rate_limit_shaped(
|
|
entry.get("last_error_code"), entry.get("last_error_reason"),
|
|
entry.get("last_error_message"))
|
|
|
|
|
|
# Throttle for the live Codex quota probe. It runs on the hot credential-selection path while the
|
|
# pool is exhausted, so without a floor a busy gateway would hammer the usage endpoint per call.
|
|
CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes
|
|
_codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {}
|
|
_codex_quota_probe_lock = threading.Lock()
|
|
|
|
|
|
def _codex_quota_probe_cache_key(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _codex_usage_probe_url(base_url: Optional[str]) -> str:
|
|
"""Resolve the Codex usage endpoint for a probe.
|
|
|
|
Mirrors the Codex CLI's PathStyle split: base URLs containing ``/backend-api`` use the ChatGPT
|
|
``/wham/usage`` path, everything else ``/api/codex/usage``. Kept local so this low-level auth
|
|
module does not import the auxiliary account-usage module.
|
|
"""
|
|
normalized = _stripped(base_url).rstrip("/") or _codex_base_url()
|
|
if normalized.endswith("/codex"):
|
|
normalized = normalized[: -len("/codex")]
|
|
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
|
|
return prefix + "/usage"
|
|
|
|
|
|
def _probe_codex_quota_restored(
|
|
access_token: Any, *, base_url: Optional[str] = None,
|
|
min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS) -> Optional[bool]:
|
|
"""Ask the Codex usage endpoint whether this account's quota is usable again.
|
|
|
|
Probes are throttled per access token (module-local cache) so the hot selection path can fire
|
|
this freely.
|
|
"""
|
|
from hermes_cli.auth import _codex_quota_probe_cache, _nonempty_str
|
|
token = _stripped(access_token)
|
|
# Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens avoids pointless
|
|
# network calls for corrupt/placeholder entries (and keeps hermetic test fixtures offline).
|
|
if not token or not _decode_jwt_claims(token):
|
|
return None
|
|
cache_key = _codex_quota_probe_cache_key(token)
|
|
now = time.monotonic()
|
|
with _codex_quota_probe_lock:
|
|
cached = _codex_quota_probe_cache.get(cache_key)
|
|
if cached is not None and (now - cached[0]) < min_interval_seconds:
|
|
return cached[1]
|
|
# Reserve the slot immediately so concurrent selectors don't stampede the endpoint.
|
|
_codex_quota_probe_cache[cache_key] = (now, None)
|
|
result: Optional[bool] = None
|
|
try:
|
|
# Account/residency headers from the JWT (required for some account shapes).
|
|
from agent.codex_headers import codex_account_headers
|
|
headers = {
|
|
"Authorization": f"Bearer {token}", "Accept": "application/json",
|
|
"User-Agent": "codex-cli", **codex_account_headers(token)}
|
|
with _codex_http_client(timeout=10.0) as client:
|
|
response = client.get(_codex_usage_probe_url(base_url), headers=headers)
|
|
if response.status_code == 200:
|
|
payload = response.json() or {}
|
|
# A model-scoped allowance (``additional_rate_limits``) at 100% still 429s that
|
|
# model, so it counts against "restored" like the account-wide windows (#97315).
|
|
windows = [payload.get("rate_limit") or {}] + [
|
|
extra.get("rate_limit") or {}
|
|
for extra in (payload.get("additional_rate_limits") or [])
|
|
if isinstance(extra, dict)]
|
|
worst_used: Optional[float] = None
|
|
for rate_limit in windows:
|
|
for key in ("primary_window", "secondary_window"):
|
|
used = (rate_limit.get(key) or {}).get("used_percent")
|
|
if isinstance(used, (int, float)):
|
|
worst_used = max(worst_used or 0.0, float(used))
|
|
if worst_used is not None:
|
|
result = worst_used < 100.0
|
|
elif response.status_code == 429:
|
|
result = False
|
|
except Exception:
|
|
logger.debug("Codex quota probe failed", exc_info=True)
|
|
result = None
|
|
with _codex_quota_probe_lock:
|
|
_codex_quota_probe_cache[cache_key] = (now, result)
|
|
return result
|
|
|
|
|
|
def _refresh_expired_codex_probe_token(
|
|
access_token: Any, refresh_token: Any, *,
|
|
min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS) -> Optional[Dict[str, Any]]:
|
|
"""Refresh an EXPIRED stored access token so the quota probe can get a real answer.
|
|
|
|
Exhausted pool entries are skipped by the proactive refresh chain (#44799), so by the time
|
|
anything probes with the stored token it has expired; the usage endpoint answers
|
|
``401 token_expired``, the probe returns None, and the cooldown is kept until
|
|
``last_error_reset_at`` no matter what happened upstream (top-up, plan upgrade) — #89415.
|
|
Returns the rotated token pair (callers MUST persist it: refresh tokens are single-use) or
|
|
None when no refresh was needed/possible. The cooldown itself is left untouched.
|
|
|
|
Shares the probe's per-token throttle: a refresh that keeps failing (revoked grant, network
|
|
down) would otherwise POST to the token endpoint on every credential selection, while the
|
|
probe itself is capped at one call per ``min_interval_seconds``. A failed attempt reserves
|
|
the stale token's probe slot, so neither the refresh nor the doomed 401 probe fire again
|
|
until the interval has elapsed.
|
|
"""
|
|
from hermes_cli.auth import _codex_quota_probe_cache
|
|
token, refresh = _stripped(access_token), _stripped(refresh_token)
|
|
if not token or not refresh or not _codex_access_token_is_expiring(token, 0):
|
|
return None
|
|
cache_key = _codex_quota_probe_cache_key(token)
|
|
now = time.monotonic()
|
|
with _codex_quota_probe_lock:
|
|
cached = _codex_quota_probe_cache.get(cache_key)
|
|
if cached is not None and (now - cached[0]) < min_interval_seconds:
|
|
return None
|
|
try:
|
|
return refresh_codex_oauth_pure(token, refresh)
|
|
except Exception:
|
|
logger.debug("Codex pre-probe token refresh failed", exc_info=True)
|
|
with _codex_quota_probe_lock:
|
|
_codex_quota_probe_cache[cache_key] = (now, None)
|
|
return None
|
|
|
|
|
|
def _probe_codex_pool_entry_quota_restored(entry: Dict[str, Any]) -> Optional[bool]:
|
|
"""``_probe_codex_quota_restored`` for a persisted pool entry, refreshing an expired token first."""
|
|
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
|
|
token = _stripped(entry.get("access_token"))
|
|
fresh = _refresh_expired_codex_probe_token(token, entry.get("refresh_token"))
|
|
if fresh:
|
|
token = fresh["access_token"]
|
|
try:
|
|
with _auth_store_lock():
|
|
auth_store = _load_auth_store()
|
|
for disk_entry in _codex_pool_dicts(_pool_entries(auth_store, "openai-codex")):
|
|
if disk_entry.get("id") == entry.get("id"):
|
|
disk_entry.update(fresh)
|
|
_save_auth_store(auth_store)
|
|
break
|
|
except Exception:
|
|
logger.debug("Failed to persist refreshed Codex pool tokens", exc_info=True)
|
|
if not token:
|
|
return None
|
|
# The row keeps the canonical URL; a gateway key belongs to its route host (#121486).
|
|
return _probe_codex_quota_restored(token, base_url=_codex_pool_route_base_url(entry.get("base_url")))
|
|
|
|
|
|
def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int:
|
|
"""Clear rate-limit cooldowns on persisted openai-codex pool entries.
|
|
|
|
Called after the upstream quota is KNOWN to be restored (a ``/usage reset`` redemption or a
|
|
positive live probe) so auth.json stops freezing credentials behind a stale
|
|
``last_error_reset_at``. With *access_token* only the matching entry clears; otherwise every
|
|
rate-limited entry does (a redeemed banked reset restores the whole account; a still-exhausted
|
|
entry just re-freezes with fresh metadata on its next 429).
|
|
"""
|
|
from agent.credential_pool import _borrowed_single_use_pool_root, _profile_owns_pool_provider
|
|
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
|
|
cleared = 0
|
|
try:
|
|
# Same owner rule as ``persist_pool_entries``: a profile with no Codex rows of its own
|
|
# borrows the global-root pool, so the cooldown must clear where the rows actually live.
|
|
target = None if _profile_owns_pool_provider("openai-codex") else _borrowed_single_use_pool_root()
|
|
with _auth_store_lock(target_path=target):
|
|
auth_store = _load_auth_store(target)
|
|
for entry in _codex_pool_dicts(_pool_entries(auth_store, "openai-codex")):
|
|
if access_token and str(entry.get("access_token") or "") != access_token:
|
|
continue
|
|
if _entry_is_rate_limit_exhausted(entry):
|
|
_clear_pool_entry_status(entry)
|
|
cleared += 1
|
|
if cleared:
|
|
_save_auth_store(auth_store, target_path=target)
|
|
except Exception:
|
|
logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True)
|
|
return cleared
|
|
|
|
|
|
def _codex_pool_dicts(entries: Optional[List[Any]]) -> Iterator[Dict[str, Any]]:
|
|
for entry in entries or ():
|
|
if isinstance(entry, dict):
|
|
yield entry
|
|
|
|
|
|
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
|
|
"""Return metadata for a pool-only Codex credential in quota cooldown.
|
|
|
|
Reads through ``read_credential_pool`` so a named profile with no Codex rows of its own sees
|
|
the global-root pool (the per-provider fallback every other pool read uses)."""
|
|
from hermes_cli.auth import _nonempty_str, read_credential_pool
|
|
from agent.credential_pool import _parse_absolute_timestamp
|
|
try:
|
|
now = time.time()
|
|
for entry in _codex_pool_dicts(read_credential_pool("openai-codex")):
|
|
token = entry.get("access_token")
|
|
if not _nonempty_str(token) or not _entry_is_rate_limit_exhausted(entry):
|
|
continue
|
|
reset_at = _parse_absolute_timestamp(entry.get("last_error_reset_at"))
|
|
if reset_at is None or reset_at > now:
|
|
return {
|
|
"label": entry.get("label"), "last_refresh": entry.get("last_refresh"),
|
|
"reset_at": reset_at, "reason": entry.get("last_error_reason"),
|
|
"message": entry.get("last_error_message"), "access_token": token.strip(),
|
|
"refresh_token": entry.get("refresh_token"), "id": entry.get("id"),
|
|
"base_url": entry.get("base_url")}
|
|
except Exception:
|
|
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _pool_entries(auth_store: Dict[str, Any], provider_id: str) -> Optional[List[Any]]:
|
|
"""``auth_store["credential_pool"][provider_id]`` when it is a list, else None."""
|
|
pool = auth_store.get("credential_pool")
|
|
entries = pool.get(provider_id) if isinstance(pool, dict) else None
|
|
return entries if isinstance(entries, list) else None
|
|
|
|
|
|
def _pool_codex_credential() -> Tuple[str, str]:
|
|
"""``(access_token, row base_url)`` of the first pool entry with a non-empty access_token that is
|
|
not in an exhaustion cooldown window, so the caller routes the token to the host that row belongs
|
|
to; ``("", "")`` when none is usable.
|
|
|
|
Fallback for ``resolve_codex_runtime_credentials`` when the singleton has no creds; reads
|
|
through ``read_credential_pool`` so a profile inherits the global-root pool (#34143)."""
|
|
from agent.credential_pool import _parse_absolute_timestamp
|
|
from hermes_cli.auth import _nonempty_str, read_credential_pool
|
|
try:
|
|
for entry in _codex_pool_dicts(read_credential_pool("openai-codex")):
|
|
token = entry.get("access_token")
|
|
# Same normaliser as ``_codex_pool_rate_limit_status``: a millisecond epoch compared
|
|
# raw reads as far-future here and as elapsed there, hiding a usable entry (#103349).
|
|
reset_at = _parse_absolute_timestamp(entry.get("last_error_reset_at"))
|
|
in_cooldown = reset_at is not None and reset_at > time.time()
|
|
if _nonempty_str(token) and not in_cooldown:
|
|
return token.strip(), _stripped(entry.get("base_url"))
|
|
except Exception:
|
|
logger.debug("Codex pool fallback lookup failed", exc_info=True)
|
|
return "", ""
|
|
|
|
|
|
def _login_openai_codex(args, pconfig: ProviderConfig, *, force_new_login: bool = False) -> None:
|
|
"""OpenAI Codex login: device code by default, browser PKCE when opted in (``--browser`` /
|
|
``auth.codex_login_flow``). Tokens stored in ~/.hermes/auth.json."""
|
|
from hermes_cli.auth import (
|
|
_codex_access_token_is_expiring, _import_codex_cli_tokens,
|
|
_offer_existing_oauth_credentials, _print_login_success, _prompt_yes_no, _save_codex_tokens,
|
|
_update_config_for_provider, resolve_codex_runtime_credentials)
|
|
from hermes_cli.auth_codex_browser import codex_oauth_login
|
|
del pconfig # kept for parity with other provider login helpers
|
|
if not force_new_login:
|
|
if _offer_existing_oauth_credentials(
|
|
"openai-codex", resolve=resolve_codex_runtime_credentials,
|
|
is_expiring=_codex_access_token_is_expiring, display_name="Codex",
|
|
default_base_url=DEFAULT_CODEX_BASE_URL,
|
|
expired_notice="Existing Codex credentials are expired. Starting fresh login..."):
|
|
return
|
|
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.")
|
|
if _prompt_yes_no(
|
|
"Import these credentials? (a separate login is recommended) [y/N]: ", default="n"):
|
|
_save_codex_tokens(cli_tokens)
|
|
config_path = _update_config_for_provider("openai-codex", _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 OAuth flow — Hermes gets its own session (device code unless the user opted in
|
|
# to the browser flow).
|
|
print()
|
|
creds = codex_oauth_login(args)
|
|
_save_codex_tokens(creds["tokens"], creds.get("last_refresh"))
|
|
config_path = _update_config_for_provider(
|
|
"openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
|
|
_print_login_success("openai-codex", config_path, show_auth_state=True)
|
|
|
|
|
|
def _codex_login_rate_limited_error(response: "httpx.Response", *, during: str = "") -> AuthError:
|
|
"""AuthError for a 429 from OpenAI's device-auth endpoints (throttle, not credential fault)."""
|
|
# Upstream rate-limit / usage-quota exhaustion on the token endpoint. The stored refresh token is still
|
|
# valid here — re-authenticating cannot lift a quota cap. Classify distinctly from auth failures so
|
|
# callers surface a "retry later" notice instead of a misleading "run hermes auth" prompt (see issue
|
|
# #32790).
|
|
retry_after = _parse_retry_after_seconds(getattr(response, "headers", None))
|
|
wait_hint = (
|
|
f" Try again in about {retry_after}s." if retry_after is not None
|
|
else " Wait a minute and run the login again.")
|
|
return _codex_err(
|
|
f"OpenAI is rate-limiting Codex login requests (HTTP 429){during}. "
|
|
f"This is a temporary throttle on OpenAI's side, not a credential problem.{wait_hint}",
|
|
CODEX_RATE_LIMITED_CODE)
|
|
|
|
|
|
def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]:
|
|
"""Step 1 of the Codex device flow: request a user code, retrying capped on HTTP 429.
|
|
|
|
OpenAI rate-limits this request when login is attempted too often from one IP/account — retry
|
|
with capped backoff (honoring ``Retry-After``) before surfacing an actionable message.
|
|
"""
|
|
max_attempts = 4
|
|
for attempt in range(1, max_attempts + 1):
|
|
resp = _codex_login_post(
|
|
f"{issuer}/api/accounts/deviceauth/usercode", json={"client_id": client_id},
|
|
headers={"Content-Type": "application/json"},
|
|
failure=("Failed to request device code", "device_code_request_failed"))
|
|
if resp.status_code != 429:
|
|
break
|
|
if attempt < max_attempts:
|
|
# Exponential backoff (2s, 4s, 8s) capped, preferring the server's Retry-After.
|
|
retry_after = _parse_retry_after_seconds(getattr(resp, "headers", None))
|
|
delay = max(1, min(int(retry_after if retry_after is not None else 2 ** attempt), 60))
|
|
print(f"OpenAI is rate-limiting login requests (429); retrying in {delay}s...")
|
|
time.sleep(delay)
|
|
if resp.status_code == 429:
|
|
raise _codex_login_rate_limited_error(resp)
|
|
if resp.status_code != 200:
|
|
raise _codex_err(
|
|
f"Device code request returned status {resp.status_code}.", "device_code_request_error")
|
|
device_data = resp.json()
|
|
device_data["interval"] = max(3, int(device_data.get("interval", "5")))
|
|
if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""):
|
|
raise _codex_err("Device code response missing required fields.", "device_code_incomplete")
|
|
return device_data
|
|
|
|
|
|
def _codex_poll_authorization_code(
|
|
issuer: str, *, device_auth_id: str, user_code: str, poll_interval: int) -> Dict[str, Any]:
|
|
"""Step 3 of the Codex device flow: poll until sign-in completes (403/404 = still pending)."""
|
|
max_wait = 15 * 60 # 15 minutes
|
|
max_consecutive_blips = 6 # survives transient drops, still fails fast on a dead network
|
|
start = time.monotonic()
|
|
code_resp = None
|
|
try:
|
|
with _codex_http_client(timeout=httpx.Timeout(15.0)) as client:
|
|
consecutive_blips = 0
|
|
while time.monotonic() - start < max_wait:
|
|
time.sleep(poll_interval)
|
|
try:
|
|
poll_resp = client.post(
|
|
f"{issuer}/api/accounts/deviceauth/token",
|
|
json={"device_auth_id": device_auth_id, "user_code": user_code},
|
|
headers={"Content-Type": "application/json"})
|
|
except Exception as exc:
|
|
if not _is_transient_transport_error(exc):
|
|
raise _codex_err(
|
|
f"Device auth polling request failed: {exc}{_ssl_interop_hint(exc)}",
|
|
"device_code_poll_error") from exc
|
|
consecutive_blips += 1
|
|
if consecutive_blips >= max_consecutive_blips:
|
|
raise _codex_err(
|
|
f"Device auth polling request failed after {consecutive_blips} consecutive"
|
|
f" transport errors: {exc}{_ssl_interop_hint(exc)}",
|
|
"device_code_poll_error") from exc
|
|
print("Transient network error while waiting for sign-in; retrying...")
|
|
continue
|
|
consecutive_blips = 0
|
|
if poll_resp.status_code == 200:
|
|
code_resp = poll_resp.json()
|
|
break
|
|
if poll_resp.status_code not in {403, 404}: # 403/404 = user hasn't finished yet
|
|
raise _codex_err(
|
|
f"Device auth polling returned status {poll_resp.status_code}.",
|
|
"device_code_poll_error")
|
|
except KeyboardInterrupt:
|
|
print("\nLogin cancelled.")
|
|
raise SystemExit(130)
|
|
if code_resp is None:
|
|
raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout")
|
|
return code_resp
|
|
|
|
|
|
def _codex_exchange_authorization_code(
|
|
issuer: str, client_id: str, code_resp: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Step 4 of the Codex device flow: swap the authorization code for tokens."""
|
|
authorization_code = code_resp.get("authorization_code", "")
|
|
code_verifier = code_resp.get("code_verifier", "")
|
|
if not authorization_code or not code_verifier:
|
|
raise _codex_err(
|
|
"Device auth response missing authorization_code or code_verifier.",
|
|
"device_code_incomplete_exchange")
|
|
token_resp = _codex_login_post(
|
|
CODEX_OAUTH_TOKEN_URL,
|
|
data={
|
|
"grant_type": "authorization_code", "code": authorization_code,
|
|
"redirect_uri": f"{issuer}/deviceauth/callback", "client_id": client_id,
|
|
"code_verifier": code_verifier},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
failure=("Token exchange failed", "token_exchange_failed"))
|
|
if token_resp.status_code == 429:
|
|
raise _codex_login_rate_limited_error(token_resp, during=" during token exchange")
|
|
if token_resp.status_code != 200:
|
|
raise _codex_err(
|
|
f"Token exchange returned status {token_resp.status_code}.", "token_exchange_error")
|
|
tokens = token_resp.json()
|
|
if not tokens.get("access_token", ""):
|
|
raise _codex_err(
|
|
"Token exchange did not return an access_token.", "token_exchange_no_access_token")
|
|
return tokens
|
|
|
|
|
|
def _codex_device_code_login() -> Dict[str, Any]:
|
|
"""Run the OpenAI device code login flow and return credentials dict."""
|
|
from hermes_cli.auth import _utc_now_z
|
|
issuer, client_id = "https://auth.openai.com", CODEX_OAUTH_CLIENT_ID
|
|
device_data = _codex_request_device_code(issuer, client_id)
|
|
user_code = device_data["user_code"]
|
|
|
|
# Step 2: Show user the code
|
|
print("To continue, follow these steps:\n")
|
|
print(" 1. Open this URL in your browser:")
|
|
print(f" \033[94m{issuer}/codex/device\033[0m\n")
|
|
print(" 2. Enter this code:")
|
|
print(f" \033[94m{user_code}\033[0m\n")
|
|
print("Waiting for sign-in... (press Ctrl+C to cancel)")
|
|
code_resp = _codex_poll_authorization_code(
|
|
issuer, device_auth_id=device_data["device_auth_id"], user_code=user_code,
|
|
poll_interval=device_data["interval"])
|
|
tokens = _codex_exchange_authorization_code(issuer, client_id, code_resp)
|
|
# Return tokens for the caller to persist (never writes to ~/.codex/)
|
|
return {
|
|
"tokens": {
|
|
"access_token": tokens.get("access_token", ""),
|
|
"refresh_token": tokens.get("refresh_token", "")},
|
|
"base_url": _codex_base_url(), "last_refresh": _utc_now_z(), "auth_mode": "chatgpt",
|
|
"source": "device-code"}
|