{"repo":"NousResearch/hermes-agent","public_id":"11364","display_label":"Codex OAuth pool entries overwritten on add/re-auth","summary":"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.","subsystem":"agent","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12360},"resolution":{"state":"partially_resolved","by":"pr-42316"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T18:57:04+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14727272727272728},"fix_edges":[{"issue_number":6651,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6907,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":11364,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12747,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33000,"pr_number":42316,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39236,"pr_number":42316,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11364,"title":"openai-codex credential pool: _sync_codex_entry_from_cli overwrites all entries with the same token","url":null,"state":"closed"},{"kind":"issue","number":39236,"title":"[Bug]: openai-codex OAuth add/reauth overwrites existing credential-pool entries while preserving stale labels","url":null,"state":"closed"},{"kind":"issue","number":42102,"title":"[Bug]: Openai-codex auth add only adds openai-codex-oauth-1 unable to add multiple","url":null,"state":"closed"},{"kind":"pr","number":11382,"title":"fix(auth): keep manual Codex pool entries isolated","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b02833f32d4b22d989c668a0d6cb2f1cf3b57f75","number":0},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#11364","number":11364},{"kind":"pr","value":"#11364","number":11364},{"kind":"pr","value":"#12924","number":12924}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11727,"title":"fix: openai-codex credential rotation — sync guard + 429 token refresh","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b02833f32","number":0},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#11364","number":11364},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#11364","number":11364},{"kind":"pr","value":"#12360","number":12360}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12360,"title":"fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/auth.py","tests/hermes_cli/test_auth_codex_provider.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -22,8 +22,6 @@\n     _auth_store_lock,\n     _codex_access_token_is_expiring,\n     _decode_jwt_claims,\n-    _import_codex_cli_tokens,\n-    _write_codex_cli_tokens,\n     _load_auth_store,\n     _load_provider_state,\n     _resolve_kimi_base_url,","@@ -457,39 +455,6 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -\n             logger.debug(\"Failed to sync from credentials file: %s\", exc)\n         return entry\n \n-    def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential:\n-        \"\"\"Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ.\n-\n-        OpenAI OAuth refresh tokens are single-use and rotate on every refresh.\n-        When the Codex CLI (or another Hermes profile) refreshes its token,\n-        the pool entry's refresh_token becomes stale.  This method detects that\n-        by comparing against ~/.codex/auth.json and syncing the fresh pair.\n-        \"\"\"\n-        if self.provider != \"openai-codex\":\n-            return entry\n-        try:\n-            cli_tokens = [redacted:secret-assignment]\n-            if not cli_tokens:\n-                return entry\n-            cli_refresh = cli_tokens.get(\"refresh_token\", \"\")\n-            cli_access = cli_tokens.get(\"access_token\", \"\")\n-            if cli_refresh and cli_refresh != entry.refresh_token:\n-                logger.debug(\"Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)\", entry.id)\n-                updated = replace(\n-                    entry,\n-                    access_token=[redacted:secret-assignment]\n-                    refresh_token=[redacted:secret-assignment]\n-                    last_status=None,\n-                    last_status_at=None,\n-                    last_error_code=None,\n-                )\n-                self._replace_entry(entry, updated)\n-                self._persist()\n-                return updated\n-        except Exception as exc:\n-            logger.debug(\"Failed to sync from ~/.codex/auth.json: %s\", exc)\n-        return entry\n-\n     def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:\n         \"\"\"Write refreshed pool entry tokens back to auth.json providers.\n ","@@ -585,13 +550,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n                     except Exception as wexc:\n                         logger.debug(\"Failed to write refreshed token to credentials file: %s\", wexc)\n             elif self.provider == \"openai-codex\":\n-                # Proactively sync from ~/.codex/auth.json before refresh.\n-                # The Codex CLI (or another Hermes profile) may have already\n-                # consumed our refresh_token.  Syncing first avoids a\n-                # \"refresh_token_reused\" error when the CLI has a newer pair.\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced is not entry:\n-                    entry = synced\n                 refreshed = auth_mod.refresh_codex_oauth_pure(\n                     entry.access_token,\n                     entry.refresh_token,","@@ -677,45 +635,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n                     # Credentials file had a valid (non-expired) token — use it directly\n                     logger.debug(\"Credentials file has valid token, using without refresh\")\n                     return synced\n-            # For openai-codex: the refresh_token may have been consumed by\n-            # the Codex CLI between our proactive sync and the refresh call.\n-            # Re-sync and retry once.\n-            if self.provider == \"openai-codex\":\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced.refresh_token != entry.refresh_token:\n-                    logger.debug(\"Retrying Codex refresh with synced token from ~/.codex/auth.json\")\n-                    try:\n-                        refreshed = auth_mod.refresh_codex_oauth_pure(\n-                            synced.access_token,\n-                            synced.refresh_token,\n-                        )\n-                        updated = replace(\n-                            synced,\n-                            access_token=[redacted:secret-assignment]\"access_token\"],\n-                            refresh_token=[redacted:secret-assignment]\"refresh_token\"],\n-                            last_refresh=refreshed.get(\"last_refresh\"),\n-                            last_status=STATUS_OK,\n-                            last_status_at=None,\n-                            last_error_code=None,\n-                        )\n-                        self._replace_entry(synced, updated)\n-                        self._persist()\n-                        self._sync_device_code_entry_to_auth_store(updated)\n-                        try:\n-                            _write_codex_cli_tokens(\n-                                updated.access_token,\n-                                updated.refresh_token,\n-                                last_refresh=updated.last_refresh,\n-                            )\n-                        except Exception as wexc:\n-                            logger.debug(\"Failed to write refreshed Codex tokens to CLI file (retry): %s\", wexc)\n-                        return updated\n-                    except Exception as retry_exc:\n-                        logger.debug(\"Codex retry refresh also failed: %s\", retry_exc)\n-                elif not self._entry_needs_refresh(synced):\n-                    logger.debug(\"Codex CLI has valid token, using without refresh\")\n-                    self._sync_device_code_entry_to_auth_store(synced)\n-                    return synced\n             self._mark_exhausted(entry, None)\n             return None\n ","@@ -734,17 +653,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n         # _seed_from_singletons() on the next load_pool() sees fresh state\n         # instead of re-seeding stale/consumed tokens.\n         self._sync_device_code_entry_to_auth_store(updated)\n-        # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI\n-        # and VS Code don't hit \"refresh_token_reused\" on their next refresh.\n-        if self.provider == \"openai-codex\":\n-            try:\n-                _write_codex_cli_tokens(\n-                    updated.access_token,\n-                    updated.refresh_token,\n-                    last_refresh=updated.last_refresh,\n-                )\n-            except Exception as wexc:\n-                logger.debug(\"Failed to write refreshed Codex tokens to CLI file: %s\", wexc)\n         return updated\n \n     def _entry_needs_refresh(self, entry: PooledCredential) -> bool:","@@ -790,16 +698,6 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n                 if synced is not entry:\n                     entry = synced\n                     cleared_any = True\n-            # For openai-codex entries, sync from ~/.codex/auth.json before\n-            # any status/refresh checks.  This picks up tokens refreshed by\n-            # the Codex CLI or another Hermes profile.\n-            if (self.provider == \"openai-codex\"\n-                    and entry.last_status == STATUS_EXHAUSTED\n-                    and entry.refresh_token):\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced is not entry:\n-                    entry = synced\n-                    cleared_any = True\n             if entry.last_status == STATUS_EXHAUSTED:\n                 exhausted_until = _exhausted_until(entry)\n                 if exhausted_until is not None and now < exhausted_until:","@@ -1218,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup\n     elif provider == \"openai-codex\":\n         # Respect user suppression — `hermes auth remove openai-codex` marks\n         # the device_code source as suppressed so it won't be re-seeded from\n-        # either the Hermes auth store or ~/.codex/auth.json.  Without this\n-        # gate the removal is instantly undone on the next load_pool() call.\n+        # the Hermes auth store.  Without this gate the removal is instantly\n+        # undone on the next load_pool() call.\n         codex_suppressed = False\n         try:\n             from hermes_cli.auth import is_source_suppressed","@@ -1231,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup\n \n         state = _load_provider_state(auth_store, \"openai-codex\")\n         tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n-        # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth\n-        # store has no tokens.  This mirrors resolve_codex_runtime_credentials()\n-        # so that load_pool() and list_authenticated_providers() detect tokens\n-        # that only exist in the Codex CLI shared file.\n-        if not (isinstance(tokens, dict) and tokens.get(\"access_token\")):\n-            try:\n-                from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens\n-                cli_tokens = [redacted:secret-assignment]\n-                if cli_tokens:\n-                    logger.info(\"Importing Codex CLI tokens into Hermes auth store.\")\n-                    _save_codex_tokens(cli_tokens)\n-                    # Re-read state after import\n-                    auth_store = _load_auth_store()\n-                    state = _load_provider_state(auth_store, \"openai-codex\")\n-                    tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n-            except Exception as exc:\n-                logger.debug(\"Codex CLI token import failed: %s\", exc)\n+        # Hermes owns its own Codex auth state — we do NOT auto-import from\n+        # ~/.codex/auth.json at pool-load time.  OAuth refresh tokens are\n+        # single-use, so sharing them with Codex CLI / VS Code causes\n+        # refresh_token_reused race failures.  Users who want to adopt\n+        # existing Codex CLI credentials get a one-time, explicit prompt\n+        # via `hermes auth openai-codex`.\n         if isinstance(tokens, dict) and tokens.get(\"access_token\"):\n             active_sources.add(\"device_code\")\n             changed |= _upsert_entry("]},{"file":"hermes_cli/auth.py","hunks":["@@ -1434,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:\n     }\n \n \n-def _write_codex_cli_tokens(\n-    access_token: str,\n-    refresh_token: str,\n-    *,\n-    last_refresh: Optional[str] = None,\n-) -> None:\n-    \"\"\"Write refreshed tokens back to ~/.codex/auth.json.\n-\n-    OpenAI OAuth refresh tokens are single-use and rotate on every refresh.\n-    When Hermes refreshes a token it consumes the old refresh_token; if we\n-    don't write the new pair back, the Codex CLI (or VS Code extension) will\n-    fail with ``refresh_token_reused`` on its next refresh attempt.\n-\n-    This mirrors the Anthropic write-back to ~/.claude/.credentials.json\n-    via ``_write_claude_code_credentials()``.\n-    \"\"\"\n-    codex_home = os.getenv(\"CODEX_HOME\", \"\").strip()\n-    if not codex_home:\n-        codex_home = str(Path.home() / \".codex\")\n-    auth_path = Path(codex_home).expanduser() / \"auth.json\"\n-    try:\n-        existing: Dict[str, Any] = {}\n-        if auth_path.is_file():\n-            existing = json.loads(auth_path.read_text(encoding=\"utf-8\"))\n-        if not isinstance(existing, dict):\n-            existing = {}\n-\n-        tokens_dict = [redacted:secret-assignment]\"tokens\")\n-        if not isinstance(tokens_dict, dict):\n-            tokens_dict = {}\n-        tokens_dict[\"access_token\"] = access_token\n-        tokens_dict[\"refresh_token\"] = refresh_token\n-        existing[\"tokens\"] = tokens_dict\n-        if last_refresh is not None:\n-            existing[\"last_refresh\"] = last_refresh\n-\n-        auth_path.parent.mkdir(parents=True, exist_ok=True)\n-        auth_path.write_text(json.dumps(existing, indent=2), encoding=\"utf-8\")\n-        auth_path.chmod(0o600)\n-    except (OSError, IOError) as exc:\n-        logger.debug(\"Failed to write refreshed tokens to %s: %s\", auth_path, exc)\n-\n-\n def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None:\n     \"\"\"Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).\"\"\"\n     if last_refresh is None:","@@ -1604,12 +1561,6 @@ def _refresh_codex_auth_tokens(\n     updated_tokens[\"refresh_token\"] = refreshed[\"refresh_token\"]\n \n     _save_codex_tokens(updated_tokens)\n-    # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.\n-    _write_codex_cli_tokens(\n-        refreshed[\"access_token\"],\n-        refreshed[\"refresh_token\"],\n-        last_refresh=refreshed.get(\"last_refresh\"),\n-    )\n     return updated_tokens\n \n ","@@ -1654,25 +1605,7 @@ def resolve_codex_runtime_credentials(\n     refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,\n ) -> Dict[str, Any]:\n     \"\"\"Resolve runtime credentials from Hermes's own Codex token store.\"\"\"\n-    try:\n-        data = _read_codex_tokens()\n-    except AuthError as orig_err:\n-        # Only attempt migration when there are NO tokens stored at all\n-        # (code == \"codex_auth_missing\"), not when tokens exist but are invalid.\n-        if orig_err.code != \"codex_auth_missing\":\n-            raise\n-\n-        # Migration: user had Codex as active provider with old storage (~/.codex/).\n-        cli_tokens = [redacted:secret-assignment]\n-        if cli_tokens:\n-            logger.info(\"Migrating Codex credentials from ~/.codex/ to Hermes auth store\")\n-            print(\"⚠️  Migrating Codex credentials to Hermes's own auth store.\")\n-            print(\"   This avoids conflicts with Codex CLI and VS Code.\")\n-            print(\"   Run `hermes auth` to create a fully independent session.\\n\")\n-            _save_codex_tokens(cli_tokens)\n-            data = _read_codex_tokens()\n-        else:\n-            raise\n+    data = _read_codex_tokens()\n     tokens = [redacted:secret-assignment]\"tokens\"])\n     access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n     refresh_timeout_seconds = float(os.getenv(\"HERMES_CODEX_REFRESH_TIMEOUT_SECONDS\", \"20\"))"]},{"file":"tests/hermes_cli/test_auth_codex_provider.py","hunks":["@@ -14,7 +14,6 @@\n     PROVIDER_REGISTRY,\n     _read_codex_tokens,\n     _save_codex_tokens,\n-    _write_codex_cli_tokens,\n     _import_codex_cli_tokens,\n     get_codex_auth_status,\n     get_provider_auth_state,","@@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):\n     assert data[\"tokens\"][\"access_token\"] == \"hermes-at\"\n \n \n-def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.\"\"\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    _write_codex_cli_tokens(\"new-access\", \"new-refresh\", last_refresh=\"2026-04-12T00:00:00Z\")\n-\n-    auth_path = codex_home / \"auth.json\"\n-    assert auth_path.exists()\n-    data = json.loads(auth_path.read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"new-access\"\n-    assert data[\"tokens\"][\"refresh_token\"] == \"new-refresh\"\n-    assert data[\"last_refresh\"] == \"2026-04-12T00:00:00Z\"\n-    # Verify file permissions are restricted\n-    assert (auth_path.stat().st_mode & 0o777) == 0o600\n-\n-\n-def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens preserves extra fields in existing auth.json.\"\"\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    codex_home.mkdir(parents=True, exist_ok=True)\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    existing = {\n-        \"tokens\": {\n-            \"access_token\": \"old-access\",\n-            \"refresh_token\": \"old-refresh\",\n-            \"extra_field\": \"preserved\",\n-        },\n-        \"last_refresh\": \"2026-01-01T00:00:00Z\",\n-        \"custom_key\": \"keep_me\",\n-    }\n-    (codex_home / \"auth.json\").write_text(json.dumps(existing))\n-\n-    _write_codex_cli_tokens(\"updated-access\", \"updated-refresh\")\n-\n-    data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"updated-access\"\n-    assert data[\"tokens\"][\"refresh_token\"] == \"updated-refresh\"\n-    assert data[\"tokens\"][\"extra_field\"] == \"preserved\"\n-    assert data[\"custom_key\"] == \"keep_me\"\n-    # last_refresh not updated since we didn't pass it\n-    assert data[\"last_refresh\"] == \"2026-01-01T00:00:00Z\"\n-\n-\n-def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens creates parent directories if missing.\"\"\"\n-    codex_home = tmp_path / \"does\" / \"not\" / \"exist\"\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    _write_codex_cli_tokens(\"at\", \"rt\")\n-\n-    assert (codex_home / \"auth.json\").exists()\n-    data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"at\"\n-\n-\n-def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch):\n-    \"\"\"After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.\"\"\"\n-    from hermes_cli.auth import _refresh_codex_auth_tokens\n-\n-    hermes_home = tmp_path / \"hermes\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    hermes_home.mkdir(parents=True, exist_ok=True)\n-    codex_home.mkdir(parents=True, exist_ok=True)\n-    (hermes_home / \"auth.json\").write_text(json.dumps({\"version\": 1, \"providers\": {}}))\n-    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    # Write initial CLI tokens\n-    (codex_home / \"auth.json\").write_text(json.dumps({\n-        \"tokens\": {\"access_token\": \"old-at\", \"refresh_token\": \"old-rt\"},\n-    }))\n-\n-    # Mock the pure refresh to return new tokens\n-    monkeypatch.setattr(\"hermes_cli.auth.refresh_codex_oauth_pure\", lambda *a, **kw: {\n-        \"access_token\": \"refreshed-at\",\n-        \"refresh_token\": \"refreshed-rt\",\n-        \"last_refresh\": \"2026-04-12T01:00:00Z\",\n-    })\n-\n-    _refresh_codex_auth_tokens(\n-        {\"access_token\": \"old-at\", \"refresh_token\": \"old-rt\"},\n-        timeout_seconds=10,\n-    )\n-\n-    # Verify CLI file was updated\n-    cli_data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert cli_data[\"tokens\"][\"access_token\"] == \"refreshed-at\"\n-    assert cli_data[\"tokens\"][\"refresh_token\"] == \"refreshed-rt\"\n-\n-\n def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):\n     hermes_home = tmp_path / \"hermes\"\n     _setup_hermes_auth(hermes_home)\n"]}]},{"kind":"pr","number":39243,"title":"fix(auth): preserve independent Codex pool entries on re-auth (#39236)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"761b744ab","number":0},{"kind":"pr","value":"#42316","number":42316}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40771,"title":"fix: preserve codex OAuth pool entries","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42110,"title":"fix(auth): propagate credential label to pool entries on re-auth","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42316,"title":"fix(auth): keep Codex OAuth pool accounts distinct on add + re-auth (#39236)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -91,6 +91,7 @@ def _load_config_safe() -> Optional[dict]:\n AUTH_TYPE_API_KEY = \"[redacted:secret-assignment]\"\n \n SOURCE_MANUAL = \"manual\"\n+SOURCE_MANUAL_DEVICE_CODE = f\"{SOURCE_MANUAL}:device_code\"\n \n STRATEGY_FILL_FIRST = \"fill_first\"\n STRATEGY_ROUND_ROBIN = \"round_robin\""]},{"file":"hermes_cli/auth.py","hunks":["@@ -1182,6 +1182,24 @@ def _store_provider_state(\n         auth_store[\"active_provider\"] = provider_id\n \n \n+def mark_provider_active_if_unset(provider_id: str) -> None:\n+    \"\"\"Set ``active_provider`` to *provider_id* only when none is set yet.\n+\n+    Used by ``hermes auth add`` OAuth paths that create credential-pool\n+    entries directly (no singleton ``providers.<id>`` block). Adding the\n+    very first credential for a provider should make it the active provider\n+    so the setup wizard's ``_model_section_has_credentials()`` check (which\n+    consults ``get_active_provider()``) does not report \"No inference\n+    provider configured\". Subsequent adds for an already-active setup leave\n+    the user's chosen active provider untouched.\n+    \"\"\"\n+    with _auth_store_lock():\n+        auth_store = _load_auth_store()\n+        if not (auth_store.get(\"active_provider\") or \"\").strip():\n+            auth_store[\"active_provider\"] = provider_id\n+            _save_auth_store(auth_store)\n+\n+\n def is_known_auth_provider(provider_id: str) -> bool:\n     normalized = (provider_id or \"\").strip().lower()\n     return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES","@@ -3355,6 +3373,7 @@ def _sync_codex_pool_entries(\n     auth_store: Dict[str, Any],\n     tokens: [redacted:secret-assignment] str],\n     last_refresh: Optional[str],\n+    previous_singleton_tokens: [redacted:secret-assignment] str]] = None,\n ) -> None:\n     \"\"\"Mirror a fresh Codex re-auth into the credential_pool OAuth entries.\n ","@@ -3370,24 +3389,34 @@ def _sync_codex_pool_entries(\n       OAuth flow when the user logged in via ``hermes setup`` / the model\n       picker.  Always synced with the fresh tokens.\n     * ``manual:device_code`` — entries created by ``hermes auth add openai-codex``\n-      that use the same device-code OAuth mechanism.  An interactive re-auth\n-      proves the user owns the ChatGPT account, so it is safe (and expected)\n-      to refresh these entries too.  Without this, a user who once ran the\n-      ``hermes auth add`` workaround for #33000 would silently leave that\n-      manual entry stale on every subsequent re-auth, recreating the issue\n-      reported in #33538.\n+      that use the same device-code OAuth mechanism.  ONLY synced if the\n+      entry's existing access_token matches the *previous* singleton\n+      access_token (i.e. the entry is a legacy singleton-alias from the\n+      #33000 workaround era).  Manual entries whose tokens never matched the\n+      singleton represent INDEPENDENT accounts added via\n+      ``hermes auth add openai-codex`` and must not be overwritten by a\n+      re-auth that targeted a different account (regression for #39236).\n+\n+      The original #33538 fix refreshed every ``manual:device_code`` entry\n+      unconditionally.  That worked when ``manual:device_code`` only meant\n+      \"legacy alias of the singleton\", but the same source string is now\n+      also produced by independent-account additions, and the broad sync\n+      silently clobbered distinct accounts with the latest-authenticated\n+      token pair.  The access_token-match check distinguishes the two cases\n+      without changing the source-string contract.\n \n     What does NOT get refreshed:\n \n     * ``manual:api_key`` and any other non-device-code manual sources — those\n       are independent credentials (an explicit API key, a different ChatGPT\n       account, etc.) and must not be overwritten by a single re-auth.\n+    * ``manual:device_code`` entries whose access_token does NOT match the\n+      previous singleton — see above; these are independent accounts.\n \n-    Error markers (``last_status``, ``last_error_*``) are also cleared on\n-    every device-code-backed entry — even those whose tokens we did not\n-    rewrite — so that an interactive re-auth gives every relevant pool entry\n-    a fresh selection chance instead of leaving them marked unhealthy from a\n-    pre-re-auth 401.\n+    Error markers (``last_status``, ``last_error_*``) are cleared ONLY on\n+    entries that actually had their tokens rewritten by this re-auth.\n+    Independent entries keep their own error state (their 401/429 markers\n+    belong to that account's own auth flow, not this re-auth).\n     \"\"\"\n     access_token = [redacted:secret-assignment]\"access_token\")\n     if not access_token:","@@ -3399,15 +3428,34 @@ def _sync_codex_pool_entries(\n     entries = pool.get(\"openai-codex\")\n     if not isinstance(entries, list):\n         return\n-    # Sources whose tokens should be rewritten by a fresh Codex device-code\n-    # OAuth re-auth.  ``manual:api_key`` and unknown sources are intentionally\n-    # excluded — they represent independent credentials.\n-    REFRESHABLE_SOURCES = {\"device_code\", \"manual:device_code\"}\n+    # Previous singleton access_token (before this re-auth overwrote it) —\n+    # used to distinguish legacy singleton-aliases from independent accounts.\n+    # When None or empty, no manual entry can be treated as an alias (which\n+    # is the right default for first-ever-save or a freshly initialized\n+    # auth.json).\n+    prev_at = None\n+    if isinstance(previous_singleton_tokens, dict):\n+        prev_at = previous_singleton_tokens.get(\"access_token\") or None\n     for entry in entries:\n         if not isinstance(entry, dict):\n             continue\n         source = entry.get(\"source\")\n-        if source not in REFRESHABLE_SOURCES:\n+        if source == \"device_code\":\n+            # Singleton-seeded mirror — always refresh.\n+            refresh_this_entry = True\n+        elif source == \"manual:device_code\":\n+            # Refresh only if this entry's existing access_token matches the\n+            # previous singleton access_token (i.e. it is a true alias of the\n+            # singleton from the #33000 workaround era).  An entry with its\n+            # own distinct token material is an independent account and must\n+            # be left alone (#39236).\n+            refresh_this_entry = bool(\n+                prev_at and entry.get(\"access_token\") == prev_at\n+            )\n+        else:\n+            # ``manual:api_key`` and any future non-device-code sources.\n+            refresh_this_entry = False\n+        if not refresh_this_entry:\n             continue\n         entry[\"access_token\"] = access_token\n         if refresh_token:","@@ -3429,13 +3477,24 @@ def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None, label:\n     with _auth_store_lock():\n         auth_store = _load_auth_store()\n         state = _load_provider_state(auth_store, \"openai-codex\") or {}\n+        # Capture the previous singleton tokens BEFORE overwriting them.  The\n+        # pool-sync step uses this to distinguish legacy singleton-aliases\n+        # (which should be refreshed) from independent accounts that\n+        # ``hermes auth add openai-codex`` created (which must not be\n+        # overwritten — see #39236).\n+        previous_singleton_tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state.get(\"tokens\"), dict) else None\n         state[\"tokens\"] = tokens\n         state[\"last_refresh\"] = last_refresh\n         state[\"auth_mode\"] = \"chatgpt\"\n         if label and str(label).strip():\n             state[\"label\"] = str(label).strip()\n         _save_provider_state(auth_store, \"openai-codex\", state)\n-        _sync_codex_pool_entries(auth_store, tokens, last_refresh)\n+        _sync_codex_pool_entries(\n+            auth_store,\n+            tokens,\n+            last_refresh,\n+            previous_singleton_tokens=[redacted:secret-assignment]\n+        )\n         _save_auth_store(auth_store)\n \n "]},{"file":"hermes_cli/auth_commands.py","hunks":["@@ -13,6 +13,7 @@\n     AUTH_TYPE_OAUTH,\n     CUSTOM_POOL_PREFIX,\n     SOURCE_MANUAL,\n+    SOURCE_MANUAL_DEVICE_CODE,\n     STATUS_EXHAUSTED,\n     STRATEGY_FILL_FIRST,\n     STRATEGY_ROUND_ROBIN,","@@ -312,15 +313,35 @@ def auth_add_command(args) -> None:\n             creds[\"tokens\"][\"access_token\"],\n             _oauth_default_label(provider, len(pool.entries()) + 1),\n         )\n-        auth_mod._save_codex_tokens(\n-            creds[\"tokens\"],\n-            last_refresh=creds.get(\"last_refresh\"),\n+        # Add a distinct, self-contained pool entry per account (matching the\n+        # xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of\n+        # routing through the singleton ``_save_codex_tokens`` save path.\n+        # The singleton round-trip collapsed every added account into the\n+        # latest login: a second ``hermes auth add openai-codex`` overwrote\n+        # the first account's singleton-mirrored ``device_code`` entry rather\n+        # than creating an independent one (#39236). ``manual:device_code``\n+        # entries refresh from their own token pair, so they need no singleton\n+        # shadow.\n+        entry = PooledCredential(\n+            provider=provider,\n+            id=uuid.uuid4().hex[:6],\n             label=label,\n+            auth_type=AUTH_TYPE_OAUTH,\n+            priority=0,\n+            source=SOURCE_MANUAL_DEVICE_CODE,\n+            access_token=[redacted:secret-assignment]\"tokens\"][\"access_token\"],\n+            refresh_token=[redacted:secret-assignment]\"tokens\"].get(\"refresh_token\"),\n+            base_url=creds.get(\"base_url\"),\n+            last_refresh=creds.get(\"last_refresh\"),\n         )\n-        pool = load_pool(provider)\n-        entry = next((item for item in pool.entries() if item.source == \"device_code\"), None)\n-        shown_label = entry.label if entry is not None else label\n-        print(f'Saved {provider} OAuth device-code credentials: \"[redacted:secret-assignment]\"')\n+        first_credential = not pool.entries()\n+        pool.add_entry(entry)\n+        # Adding the first Codex credential should make it the active provider\n+        # (the old singleton save path did this implicitly via\n+        # _save_provider_state). Subsequent adds leave the active provider as-is.\n+        if first_credential:\n+            auth_mod.mark_provider_active_if_unset(provider)\n+        print(f'Added {provider} OAuth credential #{len(pool.entries())}: \"{entry.label}\"')\n         return\n \n     if provider == \"xai-oauth\":"]},{"file":"scripts/release.py","hunks":["@@ -66,6 +66,7 @@\n     \"[redacted:email]\": \"HeLLGURD\",\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"temalo\",\n     \"[redacted:email]\": \"alarcritty\",\n     \"[redacted:email]\": \"islam666\",\n     \"[redacted:email]\": \"lsaether\","]},{"file":"tests/hermes_cli/test_auth_codex_provider.py","hunks":["@@ -301,19 +301,23 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):\n \n \n def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):\n-    \"\"\"Re-auth must also refresh ``manual:device_code`` pool entries.\n-\n-    Regression for #33538: a user who hit #33000 before the #33164 fix landed\n-    would have run ``hermes auth add openai-codex`` as a workaround, leaving\n-    a pool entry with ``source=\"manual:device_code\"``.  On every subsequent\n-    re-auth via setup/model picker, the singleton-seeded ``device_code`` entry\n-    got refreshed but the ``manual:device_code`` entry stayed stale, recreating\n-    the same 401 token_invalidated symptom that #33164 was supposed to fix.\n-\n-    An interactive Codex device-code re-auth proves the user owns the ChatGPT\n-    account, so it is safe to refresh every device-code-backed entry in the\n-    pool — but NOT independent ``manual:api_key`` entries (separate accounts /\n-    explicit API keys).\n+    \"\"\"Re-auth must refresh ``manual:device_code`` entries that are true\n+    aliases of the singleton, while leaving INDEPENDENT entries alone.\n+\n+    Original regression for #33538: a user who hit #33000 before the #33164\n+    fix landed would have run ``hermes auth add openai-codex`` as a\n+    workaround, leaving a pool entry with ``source=\"manual:device_code\"``.\n+    On every subsequent re-auth via setup/model picker, the singleton-seeded\n+    ``device_code`` entry got refreshed but the ``manual:device_code`` entry\n+    stayed stale, recreating the same 401 token_invalidated symptom that\n+    #33164 was supposed to fix.\n+\n+    Narrowed for #39236: the original fix treated every ``manual:device_code``\n+    entry as a singleton-alias and refreshed them all, which silently\n+    clobbered independent accounts added via ``hermes auth add openai-codex``.\n+    The current behavior refreshes only entries whose access_token matches\n+    the *previous* singleton access_token (true legacy aliases), and leaves\n+    distinct-token entries alone (independent accounts).\n     \"\"\"\n     hermes_home = tmp_path / \"hermes\"\n     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\n                     \"access_token\": \"old-at\",\n                     \"refresh_token\": \"old-rt\",\n                 },\n+                # Legacy alias from the #33000 workaround era — its tokens\n+                # match the singleton, so it is a true alias and SHOULD be\n+                # refreshed (preserves #33538 behavior).\n                 {\n-                    \"id\": \"auth-add\",\n+                    \"id\": \"legacy-alias\",\n                     \"source\": \"manual:device_code\",\n                     \"auth_type\": \"oauth\",\n-                    \"access_token\": \"stale-manual-at\",\n-                    \"refresh_token\": \"stale-manual-rt\",\n+                    \"access_token\": \"old-at\",\n+                    \"refresh_token\": \"old-rt\",\n                     \"last_status\": \"exhausted\",\n                     \"last_error_code\": 401,\n                     \"last_error_reason\": \"token_invalidated\",\n                 },\n+                # Independent account from `hermes auth add openai-codex` —\n+                # its tokens are distinct from the singleton.  Must NOT be\n+                # overwritten by a re-auth that targeted a different account\n+                # (#39236).\n+                {\n+                    \"id\": \"independent\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"independent-at\",\n+                    \"refresh_token\": \"independent-rt\",\n+                },\n                 {\n                     \"id\": \"api-key\",\n                     \"source\": \"manual:api_key\",","@@ -363,18 +381,23 @@ def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatc\n     pool = auth[\"credential_pool\"][\"openai-codex\"]\n \n     # Singleton-seeded device_code entry: refreshed and error markers cleared.\n-    seeded = next(e for e in pool if e[\"source\"] == \"device_code\")\n+    seeded = next(e for e in pool if e[\"id\"] == \"seeded\")\n     assert seeded[\"access_token\"] == \"fresh-at\"\n     assert seeded[\"refresh_token\"] == \"fresh-rt\"\n \n-    # manual:device_code entry: ALSO refreshed (the new behavior).\n-    manual_dc = next(e for e in pool if e[\"source\"] == \"manual:device_code\")\n-    assert manual_dc[\"access_token\"] == \"fresh-at\"\n-    assert manual_dc[\"refresh_token\"] == \"fresh-rt\"\n-    assert manual_dc[\"last_refresh\"] == \"2026-05-28T00:00:00Z\"\n-    assert manual_dc[\"last_status\"] is None\n-    assert manual_dc[\"last_error_code\"] is None\n-    assert manual_dc[\"last_error_reason\"] is None\n+    # Legacy alias (tokens matched previous singleton): ALSO refreshed.\n+    legacy = next(e for e in pool if e[\"id\"] == \"legacy-alias\")\n+    assert legacy[\"access_token\"] == \"fresh-at\"\n+    assert legacy[\"refresh_token\"] == \"fresh-rt\"\n+    assert legacy[\"last_refresh\"] == \"2026-05-28T00:00:00Z\"\n+    assert legacy[\"last_status\"] is None\n+    assert legacy[\"last_error_code\"] is None\n+    assert legacy[\"last_error_reason\"] is None\n+\n+    # Independent manual:device_code entry: NOT overwritten (#39236).\n+    independent = next(e for e in pool if e[\"id\"] == \"independent\")\n+    assert independent[\"access_token\"] == \"independent-at\"\n+    assert independent[\"refresh_token\"] == \"independent-rt\"\n \n     # manual:api_key entry: untouched — independent credential.\n     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\n     assert \"refresh_token\" not in api_key or api_key.get(\"refresh_token\") is None\n \n \n+def test_save_codex_tokens_does_not_overwrite_independent_manual_entries(tmp_path, monkeypatch):\n+    \"\"\"Re-auth must NOT overwrite ``manual:device_code`` entries that hold\n+    independent token material (different OpenAI/ChatGPT accounts).\n+\n+    Regression for #39236: ``hermes auth add openai-codex`` for accounts B and C\n+    routes through ``_save_codex_tokens`` because the singleton path is the\n+    only Codex OAuth save flow.  The #33538 fix refreshed every\n+    ``manual:device_code`` entry on every re-auth, which works fine for the\n+    one-account/legacy-workaround case but silently overwrote distinct\n+    independent accounts with the latest-authenticated tokens (labels\n+    preserved, token material clobbered, status/quota readings then lie).\n+\n+    The safe invariant: an entry is a singleton-alias only when its current\n+    access_token matches the *previous* singleton access_token.  Manual\n+    entries whose tokens never matched the singleton are independent accounts\n+    and must be left alone.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {\n+            \"openai-codex\": {\n+                # Old singleton tokens — represent \"account A\" which the user\n+                # logged in with via setup originally.\n+                \"tokens\": {\"access_token\": \"acctA-at\", \"refresh_token\": \"acctA-rt\"},\n+                \"last_refresh\": \"2026-01-01T00:00:00Z\",\n+                \"auth_mode\": \"chatgpt\",\n+                \"label\": \"account-A\",\n+            },\n+        },\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                # The seeded singleton mirror of account A.\n+                {\n+                    \"id\": \"seeded\",\n+                    \"label\": \"account-A\",\n+                    \"source\": \"device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"acctA-at\",\n+                    \"refresh_token\": \"acctA-rt\",\n+                },\n+                # Two INDEPENDENT manual entries added later via\n+                # ``hermes auth add openai-codex`` (account B and account C).\n+                # Each has its OWN distinct token material, unrelated to the\n+                # singleton.\n+                {\n+                    \"id\": \"acctB\",\n+                    \"label\": \"account-B\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"acctB-at\",\n+                    \"refresh_token\": \"acctB-rt\",\n+                },\n+                {\n+                    \"id\": \"acctC\",\n+                    \"label\": \"account-C\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"acctC-at\",\n+                    \"refresh_token\": \"acctC-rt\",\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    # User re-authenticates account A — fresh device-code login produces new\n+    # tokens.  The legitimate update is the seeded singleton mirror; the\n+    # independent acctB/acctC entries must be untouched.\n+    _save_codex_tokens(\n+        {\"access_token\": \"acctA-new-at\", \"refresh_token\": \"acctA-new-rt\"},\n+        last_refresh=\"2026-06-05T00:00:00Z\",\n+    )\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+\n+    # Singleton-seeded entry: refreshed (legitimate sync).\n+    seeded = next(e for e in pool if e[\"source\"] == \"device_code\")\n+    assert seeded[\"access_token\"] == \"acctA-new-at\"\n+    assert seeded[\"refresh_token\"] == \"acctA-new-rt\"\n+    assert seeded[\"last_refresh\"] == \"2026-06-05T00:00:00Z\"\n+\n+    # acctB: INDEPENDENT entry — must NOT be overwritten.\n+    acctB = next(e for e in pool if e[\"id\"] == \"acctB\")\n+    assert acctB[\"access_token\"] == \"acctB-at\", (\n+        \"acctB was clobbered by acctA re-auth (#39236 regression)\"\n+    )\n+    assert acctB[\"refresh_token\"] == \"acctB-rt\"\n+\n+    # acctC: INDEPENDENT entry — must NOT be overwritten.\n+    acctC = next(e for e in pool if e[\"id\"] == \"acctC\")\n+    assert acctC[\"access_token\"] == \"acctC-at\", (\n+        \"acctC was clobbered by acctA re-auth (#39236 regression)\"\n+    )\n+    assert acctC[\"refresh_token\"] == \"acctC-rt\"\n+\n+\n+def test_save_codex_tokens_still_refreshes_legacy_manual_alias(tmp_path, monkeypatch):\n+    \"\"\"The #33538 legacy use case must keep working.\n+\n+    A user who hit #33000 before the #33164 fix landed might have run\n+    ``hermes auth add openai-codex`` as a workaround when there was no\n+    singleton entry — that created a ``manual:device_code`` pool entry that\n+    holds the SAME token material as the (later) singleton.  This entry is a\n+    true alias of the singleton and SHOULD still be refreshed on subsequent\n+    re-auths, otherwise it goes stale and recreates the #33538 symptom.\n+\n+    The distinguishing signal: a legacy alias has access_token == previous\n+    singleton access_token; an independent account does not.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {\n+            \"openai-codex\": {\n+                \"tokens\": {\"access_token\": \"shared-at\", \"refresh_token\": \"shared-rt\"},\n+                \"last_refresh\": \"2026-01-01T00:00:00Z\",\n+                \"auth_mode\": \"chatgpt\",\n+            },\n+        },\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                {\n+                    \"id\": \"seeded\",\n+                    \"source\": \"device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"shared-at\",\n+                    \"refresh_token\": \"shared-rt\",\n+                },\n+                {\n+                    \"id\": \"legacy\",\n+                    \"label\": \"legacy-alias\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    # Token material matches the singleton — this is a true\n+                    # alias from the #33000 workaround era.\n+                    \"access_token\": \"shared-at\",\n+                    \"refresh_token\": \"shared-rt\",\n+                    \"last_status\": \"exhausted\",\n+                    \"last_error_code\": 401,\n+                    \"last_error_reason\": \"token_invalidated\",\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    _save_codex_tokens(\n+        {\"access_token\": \"fresh-at\", \"refresh_token\": \"fresh-rt\"},\n+        last_refresh=\"2026-06-05T00:00:00Z\",\n+    )\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+\n+    # Singleton: refreshed.\n+    seeded = next(e for e in pool if e[\"source\"] == \"device_code\")\n+    assert seeded[\"access_token\"] == \"fresh-at\"\n+\n+    # Legacy alias: still refreshed (preserves #33538 fix).\n+    legacy = next(e for e in pool if e[\"id\"] == \"legacy\")\n+    assert legacy[\"access_token\"] == \"fresh-at\"\n+    assert legacy[\"refresh_token\"] == \"fresh-rt\"\n+    assert legacy[\"last_refresh\"] == \"2026-06-05T00:00:00Z\"\n+    # Error markers cleared on the refreshed entry.\n+    assert legacy[\"last_status\"] is None\n+    assert legacy[\"last_error_code\"] is None\n+    assert legacy[\"last_error_reason\"] is None\n+\n+\n+def test_save_codex_tokens_handles_missing_previous_singleton_tokens(tmp_path, monkeypatch):\n+    \"\"\"First-ever Codex save (no prior singleton tokens) must not crash.\n+\n+    Edge case: a user has only pool entries (e.g. via direct auth.json edit\n+    or a partial state from a corrupted upgrade), no `providers.openai-codex.tokens`\n+    block at all.  The previous-singleton-tokens guard must handle missing\n+    state gracefully — fall back to \"no previous tokens\", which means no\n+    pool entry can be a true alias and only the singleton-seeded entry gets\n+    written.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {},\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                {\n+                    \"id\": \"preexisting\",\n+                    \"label\": \"pre-existing-manual\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"preexisting-at\",\n+                    \"refresh_token\": \"preexisting-rt\",\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    _save_codex_tokens(\n+        {\"access_token\": \"first-at\", \"refresh_token\": \"first-rt\"},\n+        last_refresh=\"2026-06-05T00:00:00Z\",\n+    )\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+    # Pre-existing independent entry with no relationship to a (now-new)\n+    # singleton MUST be preserved.\n+    pre = next(e for e in pool if e[\"id\"] == \"preexisting\")\n+    assert pre[\"access_token\"] == \"preexisting-at\"\n+    assert pre[\"refresh_token\"] == \"preexisting-rt\"\n+\n+\n+def test_save_codex_tokens_alias_match_uses_access_token_only(tmp_path, monkeypatch):\n+    \"\"\"A manual entry counts as an alias if its access_token matches the\n+    previous singleton access_token, regardless of refresh_token presence.\n+\n+    Some legacy entries (older auth.json schemas, pre-refresh-token versions)\n+    have access_token but no refresh_token.  These should still be treated as\n+    aliases when the access_token matches.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {\n+            \"openai-codex\": {\n+                \"tokens\": {\"access_token\": \"shared-at\", \"refresh_token\": \"shared-rt\"},\n+                \"auth_mode\": \"chatgpt\",\n+            },\n+        },\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                {\n+                    \"id\": \"alias-no-refresh\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"shared-at\",\n+                    # No refresh_token at all — legacy schema.\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    _save_codex_tokens(\n+        {\"access_token\": \"new-at\", \"refresh_token\": \"new-rt\"},\n+        last_refresh=\"2026-06-05T00:00:00Z\",\n+    )\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+    alias = next(e for e in pool if e[\"id\"] == \"alias-no-refresh\")\n+    # Treated as alias → refreshed with new tokens.\n+    assert alias[\"access_token\"] == \"new-at\"\n+    assert alias[\"refresh_token\"] == \"new-rt\"\n+\n+\n+def test_save_codex_tokens_clears_error_markers_only_on_refreshed_entries(tmp_path, monkeypatch):\n+    \"\"\"Error markers must be cleared only on entries that were actually\n+    refreshed by this re-auth.  Independent ``manual:device_code`` entries\n+    with their own stale-error markers must be left alone (their stale state\n+    is not the current re-auth's business).\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {\n+            \"openai-codex\": {\n+                \"tokens\": {\"access_token\": \"acctA-at\", \"refresh_token\": \"acctA-rt\"},\n+                \"auth_mode\": \"chatgpt\",\n+            },\n+        },\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                {\n+                    \"id\": \"seeded\",\n+                    \"source\": \"device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"acctA-at\",\n+                    \"refresh_token\": \"acctA-rt\",\n+                    \"last_status\": \"exhausted\",\n+                    \"last_error_code\": 401,\n+                },\n+                {\n+                    \"id\": \"acctB\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"acctB-at\",\n+                    \"refresh_token\": \"acctB-rt\",\n+                    \"last_status\": \"exhausted\",\n+                    \"last_error_code\": 429,\n+                    \"last_error_reason\": \"quota_exhausted\",\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    _save_codex_tokens(\n+        {\"access_token\": \"fresh-at\", \"refresh_token\": \"fresh-rt\"},\n+        last_refresh=\"2026-06-05T00:00:00Z\",\n+    )\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+\n+    # Singleton: refreshed AND error markers cleared.\n+    seeded = next(e for e in pool if e[\"id\"] == \"seeded\")\n+    assert seeded[\"access_token\"] == \"fresh-at\"\n+    assert seeded[\"last_status\"] is None\n+    assert seeded[\"last_error_code\"] is None\n+\n+    # Independent acctB: NOT refreshed AND error markers NOT cleared.\n+    # (Its 429 quota state belongs to acctB's own account, not acctA's re-auth.)\n+    acctB = next(e for e in pool if e[\"id\"] == \"acctB\")\n+    assert acctB[\"access_token\"] == \"acctB-at\"  # not overwritten\n+    assert acctB[\"last_status\"] == \"exhausted\"  # not cleared\n+    assert acctB[\"last_error_code\"] == 429\n+    assert acctB[\"last_error_reason\"] == \"quota_exhausted\"\n+\n+\n def test_import_codex_cli_tokens(tmp_path, monkeypatch):\n     codex_home = tmp_path / \"codex-cli\"\n     codex_home.mkdir(parents=True, exist_ok=True)"]},{"file":"tests/hermes_cli/test_auth_commands.py","hunks":["@@ -397,15 +397,92 @@ class _Args:\n \n     payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n     entries = payload[\"credential_pool\"][\"openai-codex\"]\n-    entry = next(item for item in entries if item[\"source\"] == \"device_code\")\n+    # The add path now creates a distinct, self-contained ``manual:device_code``\n+    # pool entry per account instead of routing through the singleton save path\n+    # (which collapsed multiple accounts into the latest login — #39236).\n+    entry = next(item for item in entries if item[\"source\"] == \"manual:device_code\")\n     assert payload[\"active_provider\"] == \"openai-codex\"\n-    assert payload[\"providers\"][\"openai-codex\"][\"tokens\"][\"access_token\"] == token\n+    # No singleton ``providers.openai-codex`` block is written by the add path.\n+    assert \"openai-codex\" not in payload.get(\"providers\", {})\n     assert entry[\"label\"] == \"[redacted:email]\"\n-    assert entry[\"source\"] == \"device_code\"\n+    assert entry[\"source\"] == \"manual:device_code\"\n+    assert entry[\"access_token\"] == token\n     assert entry[\"refresh_token\"] == \"refresh-token\"\n     assert entry[\"base_url\"] == \"https://chatgpt.com/backend-api/codex\"\n \n \n+def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch):\n+    \"\"\"Two ``hermes auth add openai-codex`` runs for different ChatGPT\n+    accounts must produce two independent pool entries with distinct tokens.\n+\n+    Regression for #39236: the add path used to route through the singleton\n+    ``_save_codex_tokens`` save, so the second login overwrote the first\n+    account's singleton-mirrored ``device_code`` entry instead of adding a\n+    second independent one. ``hermes auth list`` showed two labels sharing\n+    one token pair, and rotation silently always used the latest account.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(tmp_path, {\"version\": 1, \"providers\": {}})\n+    first_token = [redacted:secret-assignment]\"[redacted:email]\")\n+    second_token = [redacted:secret-assignment]\"[redacted:email]\")\n+    logins = iter(\n+        [\n+            {\n+                \"tokens\": {\n+                    \"access_token\": first_token,\n+                    \"refresh_token\": \"first-refresh-token\",\n+                },\n+                \"base_url\": \"https://chatgpt.com/backend-api/codex\",\n+                \"last_refresh\": \"2026-03-23T10:00:00Z\",\n+            },\n+            {\n+                \"tokens\": {\n+                    \"access_token\": second_token,\n+                    \"refresh_token\": \"second-refresh-token\",\n+                },\n+                \"base_url\": \"https://chatgpt.com/backend-api/codex\",\n+                \"last_refresh\": \"2026-03-23T10:05:00Z\",\n+            },\n+        ]\n+    )\n+    monkeypatch.setattr(\"hermes_cli.auth._codex_device_code_login\", lambda: next(logins))\n+\n+    from hermes_cli.auth_commands import auth_add_command\n+    from agent.credential_pool import load_pool\n+\n+    class _Args:\n+        provider = \"openai-codex\"\n+        auth_type = \"oauth\"\n+        api_key = None\n+        label = None\n+\n+    auth_add_command(_Args())\n+    auth_add_command(_Args())\n+\n+    pool = load_pool(\"openai-codex\")\n+    entries = pool.entries()\n+\n+    assert [entry.source for entry in entries] == [\n+        \"manual:device_code\",\n+        \"manual:device_code\",\n+    ]\n+    assert [entry.label for entry in entries] == [\n+        \"[redacted:email]\",\n+        \"[redacted:email]\",\n+    ]\n+    assert [entry.access_token for entry in entries] == [first_token, second_token]\n+    assert [entry.refresh_token for entry in entries] == [\n+        \"first-refresh-token\",\n+        \"second-refresh-token\",\n+    ]\n+\n+    payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    # No singleton block — the add path is now pool-only.\n+    assert \"openai-codex\" not in payload.get(\"providers\", {})\n+    # First add activated the provider; second add left it as-is.\n+    assert payload[\"active_provider\"] == \"openai-codex\"\n+\n+\n def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):\n     \"\"\"hermes auth add xai-oauth must write providers singleton and set active_provider.\n ","@@ -1313,9 +1390,9 @@ class _Args:\n     payload = json.loads((hermes_home / \"auth.json\").read_text())\n     # Suppression marker must be cleared\n     assert \"openai-codex\" not in payload.get(\"suppressed_sources\", {})\n-    # New pool entry must be present\n+    # New pool entry must be present (distinct manual:device_code entry — #39236)\n     entries = payload[\"credential_pool\"][\"openai-codex\"]\n-    assert any(e[\"source\"] == \"device_code\" for e in entries)\n+    assert any(e[\"source\"] == \"manual:device_code\" for e in entries)\n     assert payload[\"active_provider\"] == \"openai-codex\"\n \n \n"]}]},{"kind":"pr","number":42434,"title":"fix(auth): preserve independent Codex OAuth profiles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"761b744abbc6","number":0},{"kind":"commit","value":"c78b3e1d3ccc","number":0},{"kind":"pr","value":"#42102","number":42102},{"kind":"pr","value":"#39236","number":39236}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":100423,"title":"fix(auth): isolate Codex singleton sync by principal","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"03fee43ca3","number":0},{"kind":"pr","value":"#115626","number":115626},{"kind":"pr","value":"#115626","number":115626}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}