Codex OAuth pool entries overwritten on add/re-auth
NousResearch/hermes-agent · cluster 11364 · agent
Data as of
Nothing open on GitHub. Fix merged in #12360. Check it covers your case.
The short answer
- Known?
- Yes. 12 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #12360. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #42316. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes concern a bug where adding or re-authenticating Codex OAuth credentials overwrites existing credential pool entries instead of keeping them distinct. The work focuses on preserving independent pool entries and their labels during sync and re-auth operations.
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 = [redacted:secret-assignment] - 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 = [redacted:secret-assignment]"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 = [redacted:secret-assignment] - 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 = [redacted:secret-assignment]"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 = [redacted:secret-assignment]"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 = [redacted:secret-assignment] - 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": "old-access", - "refresh_token": "old-refresh", - "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": "old-at", "refresh_token": "old-rt"}, - })) - - # Mock the pure refresh to return new tokens - monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { - "access_token": "refreshed-at", - "refresh_token": "refreshed-rt", - "last_refresh": "2026-04-12T01:00:00Z", - }) - - _refresh_codex_auth_tokens( - {"access_token": "old-at", "refresh_token": "old-rt"}, - 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)
-
-
#42316 fix(auth): keep Codex OAuth pool accounts distinct on add + re-auth (#39236)
files:
agent/credential_pool.py,hermes_cli/auth.py,hermes_cli/auth_commands.py,scripts/release.py,tests/hermes_cli/test_auth_codex_provider.py,tests/hermes_cli/test_auth_commands.pyShow the diff
-
agent/credential_pool.py@@ -91,6 +91,7 @@ def _load_config_safe() -> Optional[dict]: AUTH_TYPE_API_KEY = "[redacted:secret-assignment]" SOURCE_MANUAL = "manual" +SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code" STRATEGY_FILL_FIRST = "fill_first" STRATEGY_ROUND_ROBIN = "round_robin" -
hermes_cli/auth.py@@ -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 @@ -3355,6 +3373,7 @@ def _sync_codex_pool_entries( auth_store: Dict[str, Any], tokens: [redacted:secret-assignment] str], last_refresh: Optional[str], + previous_singleton_tokens: [redacted:secret-assignment] str]] = None, ) -> None: """Mirror a fresh Codex re-auth into the credential_pool OAuth entries. @@ -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 = [redacted:secret-assignment]"access_token") if not access_token: @@ -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: @@ -3429,13 +3477,24 @@ def _save_codex_tokens(tokens: [redacted:secret-assignment] 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 = [redacted:secret-assignment]"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=[redacted:secret-assignment] + ) _save_auth_store(auth_store) -
hermes_cli/auth_commands.py@@ -13,6 +13,7 @@ AUTH_TYPE_OAUTH, CUSTOM_POOL_PREFIX, SOURCE_MANUAL, + SOURCE_MANUAL_DEVICE_CODE, STATUS_EXHAUSTED, STRATEGY_FILL_FIRST, STRATEGY_ROUND_ROBIN, @@ -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=[redacted:secret-assignment]"tokens"]["access_token"], + refresh_token=[redacted:secret-assignment]"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: "[redacted:secret-assignment]"') + 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": -
scripts/release.py@@ -66,6 +66,7 @@ "[redacted:email]": "HeLLGURD", "[redacted:email]": "synapsesx", "[redacted:email]": "dirtyren", + "[redacted:email]": "temalo", "[redacted:email]": "alarcritty", "[redacted:email]": "islam666", "[redacted:email]": "lsaether", -
tests/hermes_cli/test_auth_codex_provider.py@@ -301,19 +301,23 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch): def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch): - """Re-auth must also refresh ``manual:device_code`` pool entries. - - Regression for #33538: a user who hit #33000 before the #33164 fix landed - would have run ``hermes auth add openai-codex`` as a workaround, leaving - a pool entry with ``source="manual:device_code"``. On every subsequent - re-auth via setup/model picker, the singleton-seeded ``device_code`` entry - got refreshed but the ``manual:device_code`` entry stayed stale, recreating - the same 401 token_invalidated symptom that #33164 was supposed to fix. - - An interactive Codex device-code re-auth proves the user owns the ChatGPT - account, so it is safe to refresh every device-code-backed entry in the - pool — but NOT independent ``manual:api_key`` entries (separate accounts / - explicit API keys). + """Re-auth must refresh ``manual:device_code`` entries that are true + aliases of the singleton, while leaving INDEPENDENT entries alone. + + Original regression for #33538: a user who hit #33000 before the #33164 + fix landed would have run ``hermes auth add openai-codex`` as a + workaround, leaving a pool entry with ``source="manual:device_code"``. + On every subsequent re-auth via setup/model picker, the singleton-seeded + ``device_code`` entry got refreshed but the ``manual:device_code`` entry + stayed stale, recreating the same 401 token_invalidated symptom that + #33164 was supposed to fix. + + Narrowed for #39236: the original fix treated every ``manual:device_code`` + entry as a singleton-alias and refreshed them all, which silently + clobbered independent accounts added via ``hermes auth add openai-codex``. + The current behavior refreshes only entries whose access_token matches + the *previous* singleton access_token (true legacy aliases), and leaves + distinct-token entries alone (independent accounts). """ hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) @@ -335,16 +339,30 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc "access_token": "old-at", "refresh_token": "old-rt", }, + # Legacy alias from the #33000 workaround era — its tokens + # match the singleton, so it is a true alias and SHOULD be + # refreshed (preserves #33538 behavior). { - "id": "auth-add", + "id": "legacy-alias", "source": "manual:device_code", "auth_type": "oauth", - "access_token": "stale-manual-at", - "refresh_token": "stale-manual-rt", + "access_token": "old-at", + "refresh_token": "old-rt", "last_status": "exhausted", "last_error_code": 401, "last_error_reason": "token_invalidated", }, + # Independent account from `hermes auth add openai-codex` — + # its tokens are distinct from the singleton. Must NOT be + # overwritten by a re-auth that targeted a different account + # (#39236). + { + "id": "independent", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "independent-at", + "refresh_token": "independent-rt", + }, { "id": "api-key", "source": "manual:api_key", @@ -363,18 +381,23 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc pool = auth["credential_pool"]["openai-codex"] # Singleton-seeded device_code entry: refreshed and error markers cleared. - seeded = next(e for e in pool if e["source"] == "device_code") + seeded = next(e for e in pool if e["id"] == "seeded") assert seeded["access_token"] == "fresh-at" assert seeded["refresh_token"] == "fresh-rt" - # manual:device_code entry: ALSO refreshed (the new behavior). - manual_dc = next(e for e in pool if e["source"] == "manual:device_code") - assert manual_dc["access_token"] == "fresh-at" - assert manual_dc["refresh_token"] == "fresh-rt" - assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z" - assert manual_dc["last_status"] is None - assert manual_dc["last_error_code"] is None - assert manual_dc["last_error_reason"] is None + # Legacy alias (tokens matched previous singleton): ALSO refreshed. + legacy = next(e for e in pool if e["id"] == "legacy-alias") + assert legacy["access_token"] == "fresh-at" + assert legacy["refresh_token"] == "fresh-rt" + assert legacy["last_refresh"] == "2026-05-28T00:00:00Z" + assert legacy["last_status"] is None + assert legacy["last_error_code"] is None + assert legacy["last_error_reason"] is None + + # Independent manual:device_code entry: NOT overwritten (#39236). + independent = next(e for e in pool if e["id"] == "independent") + assert independent["access_token"] == "independent-at" + assert independent["refresh_token"] == "independent-rt" # manual:api_key entry: untouched — independent credential. api_key = [redacted:secret-assignment] for e in pool if e["source"] == "manual:api_key") @@ -382,6 +405,333 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc assert "refresh_token" not in api_key or api_key.get("refresh_token") is None +def test_save_codex_tokens_does_not_overwrite_independent_manual_entries(tmp_path, monkeypatch): + """Re-auth must NOT overwrite ``manual:device_code`` entries that hold + independent token material (different OpenAI/ChatGPT accounts). + + Regression for #39236: ``hermes auth add openai-codex`` for accounts B and C + routes through ``_save_codex_tokens`` because the singleton path is the + only Codex OAuth save flow. The #33538 fix refreshed every + ``manual:device_code`` entry on every re-auth, which works fine for the + one-account/legacy-workaround case but silently overwrote distinct + independent accounts with the latest-authenticated tokens (labels + preserved, token material clobbered, status/quota readings then lie). + + The safe invariant: an entry is a singleton-alias only when its current + access_token matches the *previous* singleton access_token. Manual + entries whose tokens never matched the singleton are independent accounts + and must be left alone. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + # Old singleton tokens — represent "account A" which the user + # logged in with via setup originally. + "tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"}, + "last_refresh": "2026-01-01T00:00:00Z", + "auth_mode": "chatgpt", + "label": "account-A", + }, + }, + "credential_pool": { + "openai-codex": [ + # The seeded singleton mirror of account A. + { + "id": "seeded", + "label": "account-A", + "source": "device_code", + "auth_type": "oauth", + "access_token": "acctA-at", + "refresh_token": "acctA-rt", + }, + # Two INDEPENDENT manual entries added later via + # ``hermes auth add openai-codex`` (account B and account C). + # Each has its OWN distinct token material, unrelated to the + # singleton. + { + "id": "acctB", + "label": "account-B", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "acctB-at", + "refresh_token": "acctB-rt", + }, + { + "id": "acctC", + "label": "account-C", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "acctC-at", + "refresh_token": "acctC-rt", + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # User re-authenticates account A — fresh device-code login produces new + # tokens. The legitimate update is the seeded singleton mirror; the + # independent acctB/acctC entries must be untouched. + _save_codex_tokens( + {"access_token": "acctA-new-at", "refresh_token": "acctA-new-rt"}, + last_refresh="2026-06-05T00:00:00Z", + ) + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + + # Singleton-seeded entry: refreshed (legitimate sync). + seeded = next(e for e in pool if e["source"] == "device_code") + assert seeded["access_token"] == "acctA-new-at" + assert seeded["refresh_token"] == "acctA-new-rt" + assert seeded["last_refresh"] == "2026-06-05T00:00:00Z" + + # acctB: INDEPENDENT entry — must NOT be overwritten. + acctB = next(e for e in pool if e["id"] == "acctB") + assert acctB["access_token"] == "acctB-at", ( + "acctB was clobbered by acctA re-auth (#39236 regression)" + ) + assert acctB["refresh_token"] == "acctB-rt" + + # acctC: INDEPENDENT entry — must NOT be overwritten. + acctC = next(e for e in pool if e["id"] == "acctC") + assert acctC["access_token"] == "acctC-at", ( + "acctC was clobbered by acctA re-auth (#39236 regression)" + ) + assert acctC["refresh_token"] == "acctC-rt" + + +def test_save_codex_tokens_still_refreshes_legacy_manual_alias(tmp_path, monkeypatch): + """The #33538 legacy use case must keep working. + + A user who hit #33000 before the #33164 fix landed might have run + ``hermes auth add openai-codex`` as a workaround when there was no + singleton entry — that created a ``manual:device_code`` pool entry that + holds the SAME token material as the (later) singleton. This entry is a + true alias of the singleton and SHOULD still be refreshed on subsequent + re-auths, otherwise it goes stale and recreates the #33538 symptom. + + The distinguishing signal: a legacy alias has access_token == previous + singleton access_token; an independent account does not. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"}, + "last_refresh": "2026-01-01T00:00:00Z", + "auth_mode": "chatgpt", + }, + }, + "credential_pool": { + "openai-codex": [ + { + "id": "seeded", + "source": "device_code", + "auth_type": "oauth", + "access_token": "shared-at", + "refresh_token": "shared-rt", + }, + { + "id": "legacy", + "label": "legacy-alias", + "source": "manual:device_code", + "auth_type": "oauth", + # Token material matches the singleton — this is a true + # alias from the #33000 workaround era. + "access_token": "shared-at", + "refresh_token": "shared-rt", + "last_status": "exhausted", + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_codex_tokens( + {"access_token": "fresh-at", "refresh_token": "fresh-rt"}, + last_refresh="2026-06-05T00:00:00Z", + ) + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + + # Singleton: refreshed. + seeded = next(e for e in pool if e["source"] == "device_code") + assert seeded["access_token"] == "fresh-at" + + # Legacy alias: still refreshed (preserves #33538 fix). + legacy = next(e for e in pool if e["id"] == "legacy") + assert legacy["access_token"] == "fresh-at" + assert legacy["refresh_token"] == "fresh-rt" + assert legacy["last_refresh"] == "2026-06-05T00:00:00Z" + # Error markers cleared on the refreshed entry. + assert legacy["last_status"] is None + assert legacy["last_error_code"] is None + assert legacy["last_error_reason"] is None + + +def test_save_codex_tokens_handles_missing_previous_singleton_tokens(tmp_path, monkeypatch): + """First-ever Codex save (no prior singleton tokens) must not crash. + + Edge case: a user has only pool entries (e.g. via direct auth.json edit + or a partial state from a corrupted upgrade), no `providers.openai-codex.tokens` + block at all. The previous-singleton-tokens guard must handle missing + state gracefully — fall back to "no previous tokens", which means no + pool entry can be a true alias and only the singleton-seeded entry gets + written. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": {}, + "credential_pool": { + "openai-codex": [ + { + "id": "preexisting", + "label": "pre-existing-manual", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "preexisting-at", + "refresh_token": "preexisting-rt", + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_codex_tokens( + {"access_token": "first-at", "refresh_token": "first-rt"}, + last_refresh="2026-06-05T00:00:00Z", + ) + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + # Pre-existing independent entry with no relationship to a (now-new) + # singleton MUST be preserved. + pre = next(e for e in pool if e["id"] == "preexisting") + assert pre["access_token"] == "preexisting-at" + assert pre["refresh_token"] == "preexisting-rt" + + +def test_save_codex_tokens_alias_match_uses_access_token_only(tmp_path, monkeypatch): + """A manual entry counts as an alias if its access_token matches the + previous singleton access_token, regardless of refresh_token presence. + + Some legacy entries (older auth.json schemas, pre-refresh-token versions) + have access_token but no refresh_token. These should still be treated as + aliases when the access_token matches. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"}, + "auth_mode": "chatgpt", + }, + }, + "credential_pool": { + "openai-codex": [ + { + "id": "alias-no-refresh", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "shared-at", + # No refresh_token at all — legacy schema. + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_codex_tokens( + {"access_token": "new-at", "refresh_token": "new-rt"}, + last_refresh="2026-06-05T00:00:00Z", + ) + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + alias = next(e for e in pool if e["id"] == "alias-no-refresh") + # Treated as alias → refreshed with new tokens. + assert alias["access_token"] == "new-at" + assert alias["refresh_token"] == "new-rt" + + +def test_save_codex_tokens_clears_error_markers_only_on_refreshed_entries(tmp_path, monkeypatch): + """Error markers must be cleared only on entries that were actually + refreshed by this re-auth. Independent ``manual:device_code`` entries + with their own stale-error markers must be left alone (their stale state + is not the current re-auth's business). + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"}, + "auth_mode": "chatgpt", + }, + }, + "credential_pool": { + "openai-codex": [ + { + "id": "seeded", + "source": "device_code", + "auth_type": "oauth", + "access_token": "acctA-at", + "refresh_token": "acctA-rt", + "last_status": "exhausted", + "last_error_code": 401, + }, + { + "id": "acctB", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "acctB-at", + "refresh_token": "acctB-rt", + "last_status": "exhausted", + "last_error_code": 429, + "last_error_reason": "quota_exhausted", + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_codex_tokens( + {"access_token": "fresh-at", "refresh_token": "fresh-rt"}, + last_refresh="2026-06-05T00:00:00Z", + ) + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + + # Singleton: refreshed AND error markers cleared. + seeded = next(e for e in pool if e["id"] == "seeded") + assert seeded["access_token"] == "fresh-at" + assert seeded["last_status"] is None + assert seeded["last_error_code"] is None + + # Independent acctB: NOT refreshed AND error markers NOT cleared. + # (Its 429 quota state belongs to acctB's own account, not acctA's re-auth.) + acctB = next(e for e in pool if e["id"] == "acctB") + assert acctB["access_token"] == "acctB-at" # not overwritten + assert acctB["last_status"] == "exhausted" # not cleared + assert acctB["last_error_code"] == 429 + assert acctB["last_error_reason"] == "quota_exhausted" + + def test_import_codex_cli_tokens(tmp_path, monkeypatch): codex_home = tmp_path / "codex-cli" codex_home.mkdir(parents=True, exist_ok=True) -
tests/hermes_cli/test_auth_commands.py@@ -397,15 +397,92 @@ class _Args: payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) entries = payload["credential_pool"]["openai-codex"] - entry = next(item for item in entries if item["source"] == "device_code") + # The add path now creates a distinct, self-contained ``manual:device_code`` + # pool entry per account instead of routing through the singleton save path + # (which collapsed multiple accounts into the latest login — #39236). + entry = next(item for item in entries if item["source"] == "manual:device_code") assert payload["active_provider"] == "openai-codex" - assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token + # No singleton ``providers.openai-codex`` block is written by the add path. + assert "openai-codex" not in payload.get("providers", {}) assert entry["label"] == "[redacted:email]" - assert entry["source"] == "device_code" + assert entry["source"] == "manual:device_code" + assert entry["access_token"] == token assert entry["refresh_token"] == "refresh-token" assert entry["base_url"] == "https://chatgpt.com/backend-api/codex" +def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch): + """Two ``hermes auth add openai-codex`` runs for different ChatGPT + accounts must produce two independent pool entries with distinct tokens. + + Regression for #39236: the add path used to route through the singleton + ``_save_codex_tokens`` save, so the second login overwrote the first + account's singleton-mirrored ``device_code`` entry instead of adding a + second independent one. ``hermes auth list`` showed two labels sharing + one token pair, and rotation silently always used the latest account. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + first_token = [redacted:secret-assignment]"[redacted:email]") + second_token = [redacted:secret-assignment]"[redacted:email]") + logins = iter( + [ + { + "tokens": { + "access_token": first_token, + "refresh_token": "first-refresh-token", + }, + "base_url": "https://chatgpt.com/backend-api/codex", + "last_refresh": "2026-03-23T10:00:00Z", + }, + { + "tokens": { + "access_token": second_token, + "refresh_token": "second-refresh-token", + }, + "base_url": "https://chatgpt.com/backend-api/codex", + "last_refresh": "2026-03-23T10:05:00Z", + }, + ] + ) + monkeypatch.setattr("hermes_cli.auth._codex_device_code_login", lambda: next(logins)) + + from hermes_cli.auth_commands import auth_add_command + from agent.credential_pool import load_pool + + class _Args: + provider = "openai-codex" + auth_type = "oauth" + api_key = None + label = None + + auth_add_command(_Args()) + auth_add_command(_Args()) + + pool = load_pool("openai-codex") + entries = pool.entries() + + assert [entry.source for entry in entries] == [ + "manual:device_code", + "manual:device_code", + ] + assert [entry.label for entry in entries] == [ + "[redacted:email]", + "[redacted:email]", + ] + assert [entry.access_token for entry in entries] == [first_token, second_token] + assert [entry.refresh_token for entry in entries] == [ + "first-refresh-token", + "second-refresh-token", + ] + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + # No singleton block — the add path is now pool-only. + assert "openai-codex" not in payload.get("providers", {}) + # First add activated the provider; second add left it as-is. + assert payload["active_provider"] == "openai-codex" + + def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch): """hermes auth add xai-oauth must write providers singleton and set active_provider. @@ -1313,9 +1390,9 @@ class _Args: payload = json.loads((hermes_home / "auth.json").read_text()) # Suppression marker must be cleared assert "openai-codex" not in payload.get("suppressed_sources", {}) - # New pool entry must be present + # New pool entry must be present (distinct manual:device_code entry — #39236) entries = payload["credential_pool"]["openai-codex"] - assert any(e["source"] == "device_code" for e in entries) + assert any(e["source"] == "manual:device_code" for e in entries) assert payload["active_provider"] == "openai-codex"
-
7 PRs closed without merging
From the close comments: done elsewhere 5 · merged via another change 2
-
#11382 fix(auth): keep manual Codex pool entries isolated · done elsewhere →
b02833f,#12360,#11364,#12924 -
#11727 fix: openai-codex credential rotation — sync guard + 429 token refresh · done elsewhere →
b02833f,#12360,#11364 -
#39243 fix(auth): preserve independent Codex pool entries on re-auth (#39236) · merged via another change →
761b744,#42316 - #40771 fix: preserve codex OAuth pool entries · done elsewhere
- #42110 fix(auth): propagate credential label to pool entries on re-auth · done elsewhere
Closed issues · 3
- Closed#11364openai-codex credential pool: _sync_codex_entry_from_cli overwrites all entries with the same token
- Closed#39236[Bug]: openai-codex OAuth add/reauth overwrites existing credential-pool entries while preserving stale labels
- Closed#42102[Bug]: Openai-codex auth add only adds openai-codex-oauth-1 unable to add multiple
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.15: 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