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

0open issues
0open PRs
12items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12360 fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json

    merged to main

    files: agent/credential_pool.py, hermes_cli/auth.py, tests/hermes_cli/test_auth_codex_provider.py

    Show 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)

    merged to main

    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.py

    Show 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
Show 2 more
  • #42434 fix(auth): preserve independent Codex OAuth profiles · done elsewhere → 761b744, c78b3e1, #42102, #39236
  • #100423 fix(auth): isolate Codex singleton sync by principal · merged via another change → 03fee43, #115626
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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#12360, #42316
Closed issues
3
Attempts
7 PRs closed without merging
Fix link checked
8 Jun 2026

For your agent

The same facts, typed.