Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def _load_config_safe() -> Optional[dict]:
AUTH_TYPE_API_KEY = "api_key"

SOURCE_MANUAL = "manual"
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"

STRATEGY_FILL_FIRST = "fill_first"
STRATEGY_ROUND_ROBIN = "round_robin"
Expand Down
93 changes: 76 additions & 17 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,24 @@ def _store_provider_state(
auth_store["active_provider"] = provider_id


def mark_provider_active_if_unset(provider_id: str) -> None:
"""Set ``active_provider`` to *provider_id* only when none is set yet.

Used by ``hermes auth add`` OAuth paths that create credential-pool
entries directly (no singleton ``providers.<id>`` block). Adding the
very first credential for a provider should make it the active provider
so the setup wizard's ``_model_section_has_credentials()`` check (which
consults ``get_active_provider()``) does not report "No inference
provider configured". Subsequent adds for an already-active setup leave
the user's chosen active provider untouched.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
if not (auth_store.get("active_provider") or "").strip():
auth_store["active_provider"] = provider_id
_save_auth_store(auth_store)


def is_known_auth_provider(provider_id: str) -> bool:
normalized = (provider_id or "").strip().lower()
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
Expand Down Expand Up @@ -3355,6 +3373,7 @@ 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.

Expand All @@ -3370,24 +3389,34 @@ def _sync_codex_pool_entries(
OAuth flow when the user logged in via ``hermes setup`` / the model
picker. Always synced with the fresh tokens.
* ``manual:device_code`` — entries created by ``hermes auth add openai-codex``
that use the same device-code OAuth mechanism. An interactive re-auth
proves the user owns the ChatGPT account, so it is safe (and expected)
to refresh these entries too. Without this, a user who once ran the
``hermes auth add`` workaround for #33000 would silently leave that
manual entry stale on every subsequent re-auth, recreating the issue
reported in #33538.
that use the same device-code OAuth mechanism. ONLY synced if the
entry's existing access_token matches the *previous* singleton
access_token (i.e. the entry is a legacy singleton-alias from the
#33000 workaround era). Manual entries whose tokens never matched the
singleton represent INDEPENDENT accounts added via
``hermes auth add openai-codex`` and must not be overwritten by a
re-auth that targeted a different account (regression for #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.

What does NOT get refreshed:

* ``manual:api_key`` and any other non-device-code manual sources — those
are independent credentials (an explicit API key, a different ChatGPT
account, etc.) and must not be overwritten by a single re-auth.
* ``manual:device_code`` entries whose access_token does NOT match the
previous singleton — see above; these are independent accounts.

Error markers (``last_status``, ``last_error_*``) are also cleared on
every device-code-backed entry — even those whose tokens we did not
rewrite — so that an interactive re-auth gives every relevant pool entry
a fresh selection chance instead of leaving them marked unhealthy from a
pre-re-auth 401.
Error markers (``last_status``, ``last_error_*``) are cleared ONLY on
entries that actually had their tokens rewritten by this re-auth.
Independent entries keep their own error state (their 401/429 markers
belong to that account's own auth flow, not this re-auth).
"""
access_token = tokens.get("access_token")
if not access_token:
Expand All @@ -3399,15 +3428,34 @@ def _sync_codex_pool_entries(
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return
# Sources whose tokens should be rewritten by a fresh Codex device-code
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
# excluded — they represent independent credentials.
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
# Previous singleton access_token (before this re-auth overwrote it) —
# used to distinguish legacy singleton-aliases from independent accounts.
# When None or empty, no manual entry can be treated as an alias (which
# is the right default for first-ever-save or a freshly initialized
# auth.json).
prev_at = None
if isinstance(previous_singleton_tokens, dict):
prev_at = previous_singleton_tokens.get("access_token") or None
for entry in entries:
if not isinstance(entry, dict):
continue
source = entry.get("source")
if source not in REFRESHABLE_SOURCES:
if source == "device_code":
# Singleton-seeded mirror — always refresh.
refresh_this_entry = True
elif source == "manual:device_code":
# Refresh only if this entry's existing access_token matches the
# previous singleton access_token (i.e. it is a true alias of the
# singleton from the #33000 workaround era). An entry with its
# own distinct token material is an independent account and must
# be left alone (#39236).
refresh_this_entry = bool(
prev_at and entry.get("access_token") == prev_at
)
else:
# ``manual:api_key`` and any future non-device-code sources.
refresh_this_entry = False
if not refresh_this_entry:
continue
entry["access_token"] = access_token
if refresh_token:
Expand All @@ -3429,13 +3477,24 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
# Capture the previous singleton tokens BEFORE overwriting them. The
# pool-sync step uses this to distinguish legacy singleton-aliases
# (which should be refreshed) from independent accounts that
# ``hermes auth add openai-codex`` created (which must not be
# overwritten — see #39236).
previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
if label and str(label).strip():
state["label"] = str(label).strip()
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
_sync_codex_pool_entries(
auth_store,
tokens,
last_refresh,
previous_singleton_tokens=previous_singleton_tokens,
)
_save_auth_store(auth_store)


Expand Down
35 changes: 28 additions & 7 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
AUTH_TYPE_OAUTH,
CUSTOM_POOL_PREFIX,
SOURCE_MANUAL,
SOURCE_MANUAL_DEVICE_CODE,
STATUS_EXHAUSTED,
STRATEGY_FILL_FIRST,
STRATEGY_ROUND_ROBIN,
Expand Down Expand Up @@ -312,15 +313,35 @@ def auth_add_command(args) -> None:
creds["tokens"]["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
auth_mod._save_codex_tokens(
creds["tokens"],
last_refresh=creds.get("last_refresh"),
# Add a distinct, self-contained pool entry per account (matching the
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
# routing through the singleton ``_save_codex_tokens`` save path.
# The singleton round-trip collapsed every added account into the
# latest login: a second ``hermes auth add openai-codex`` overwrote
# the first account's singleton-mirrored ``device_code`` entry rather
# than creating an independent one (#39236). ``manual:device_code``
# entries refresh from their own token pair, so they need no singleton
# shadow.
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=SOURCE_MANUAL_DEVICE_CODE,
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
last_refresh=creds.get("last_refresh"),
)
pool = load_pool(provider)
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
shown_label = entry.label if entry is not None else label
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
first_credential = not pool.entries()
pool.add_entry(entry)
# Adding the first Codex credential should make it the active provider
# (the old singleton save path did this implicitly via
# _save_provider_state). Subsequent adds leave the active provider as-is.
if first_credential:
auth_mod.mark_provider_active_if_unset(provider)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return

if provider == "xai-oauth":
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"dirtyren@users.noreply.github.com": "dirtyren",
"ted.malone@outlook.com": "temalo",
"adityamalik2833@gmail.com": "alarcritty",
"islam666@users.noreply.github.com": "islam666",
"25539605+lsaether@users.noreply.github.com": "lsaether",
Expand Down
Loading
Loading