Auth credential removal undermined by automatic re-seeding

NousResearch/hermes-agent · cluster 9528 · dashboard

Data as of

Nothing open on GitHub. Fix merged in #12360. Check it covers your case.

The short answer

0open issues
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 4 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 #67429. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes center on credential removal operations being silently reversed by automatic re-import from sources like ~/.codex/auth.json or singleton auth stores. Work spans CLI auth commands, dashboard pool deletes, and the openai-codex provider.

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)
  • #67429 fix(credentials): pool entry delete via API sticks — suppress source re-seeding (#55217)

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_dashboard_admin_endpoints.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -12498,6 +12498,7 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):
               load_pool,
               PooledCredential,
               AUTH_TYPE_API_KEY,
      +        CUSTOM_POOL_PREFIX,
               SOURCE_MANUAL,
           )
       
      @@ -12519,6 +12520,23 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):
                   access_token=[redacted:secret-assignment]
               )
               pool.add_entry(entry)
      +        # Re-adding a credential is an explicit re-engagement signal: lift
      +        # every suppression for this provider so a source deleted earlier
      +        # (via DELETE below or `hermes auth remove`) can seed again.
      +        # Mirrors the `hermes auth add` behaviour in auth_commands.py.
      +        if not provider.startswith(CUSTOM_POOL_PREFIX):
      +            try:
      +                from hermes_cli.auth import (
      +                    _load_auth_store,
      +                    unsuppress_credential_source,
      +                )
      +                suppressed = _load_auth_store().get("suppressed_sources", {})
      +                for src in list(suppressed.get(provider, []) or []):
      +                    unsuppress_credential_source(provider, src)
      +            except Exception:
      +                _log.exception("unsuppress after pool add failed (non-fatal)")
      +    except HTTPException:
      +        raise
           except Exception as exc:
               _log.exception("POST /api/credentials/pool failed")
               raise HTTPException(status_code=400, detail=str(exc)) from exc
      @@ -12527,8 +12545,20 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):
       
       @app.delete("/api/credentials/pool/{provider}/{index}")
       async def remove_credential_pool_entry(provider: str, index: int):
      -    """Remove a pool entry.  ``index`` is 1-based (matches the list response)."""
      +    """Remove a pool entry.  ``index`` is 1-based (matches the list response).
      +
      +    Removal must be sticky (#55217): ``load_pool()`` re-seeds entries from
      +    their backing source (.env var, OAuth singleton file, custom-provider
      +    config) on every call, so deleting only the pool row silently reverts on
      +    the next dashboard refresh.  We dispatch through the same RemovalStep
      +    registry the CLI ``hermes auth remove`` uses: each source cleans up its
      +    external state and suppresses ``(provider, source)`` so the seeders skip
      +    it.  Manual entries have no registered step — nothing external to clean,
      +    no suppression needed (they aren't re-seeded).
      +    """
           from agent.credential_pool import load_pool
      +    from agent.credential_sources import find_removal_step
      +    from hermes_cli.auth import suppress_credential_source
       
           provider = (provider or "").strip().lower()
           try:
      @@ -12539,7 +12569,36 @@ async def remove_credential_pool_entry(provider: str, index: int):
               raise HTTPException(status_code=400, detail=str(exc)) from exc
           if removed is None:
               raise HTTPException(status_code=404, detail="No pool entry at that index")
      -    return {"ok": True, "provider": provider, "count": len(pool.entries())}
      +
      +    cleaned: List[str] = []
      +    hints: List[str] = []
      +    step = find_removal_step(provider, removed.source or "")
      +    if step is not None:
      +        try:
      +            result = step.remove_fn(provider, removed)
      +            cleaned = list(result.cleaned)
      +            hints = list(result.hints)
      +            if result.suppress:
      +                suppress_credential_source(provider, removed.source)
      +        except Exception:
      +            # Cleanup is best-effort, but suppression is the actual bug fix —
      +            # without it the entry resurrects on the next load_pool().  Apply
      +            # it even when source-specific cleanup blew up.
      +            _log.exception(
      +                "credential source cleanup failed for %s/%s; suppressing anyway",
      +                provider, removed.source,
      +            )
      +            try:
      +                suppress_credential_source(provider, removed.source)
      +            except Exception:
      +                _log.exception("suppress_credential_source failed")
      +    return {
      +        "ok": True,
      +        "provider": provider,
      +        "count": len(pool.entries()),
      +        "cleaned": cleaned,
      +        "hints": hints,
      +    }
       
       
       # ---------------------------------------------------------------------------
    • tests/hermes_cli/test_dashboard_admin_endpoints.py
      @@ -253,6 +253,104 @@ def test_empty_body_rejected(self):
               )
               assert r.status_code == 400
       
      +    def test_env_seeded_delete_stays_deleted(self):
      +        """#55217: DELETE must suppress the source or load_pool() resurrects it.
      +
      +        load_pool() re-seeds from ~/.hermes/.env on every call, so removing
      +        just the pool row silently reverts on the next dashboard refresh.
      +        The endpoint must mirror `hermes auth remove`: clean up the backing
      +        source and suppress (provider, source).
      +        """
      +        from agent.credential_pool import load_pool
      +        from hermes_cli.auth import is_source_suppressed
      +        from hermes_cli.config import save_env_value
      +
      +        fake_key = "sk-or-" + "x" * 20  # constructed, never a real key shape
      +        save_env_value("OPENROUTER_API_KEY", fake_key)
      +
      +        entries = load_pool("openrouter").entries()
      +        assert [e.source for e in entries] == ["env:OPENROUTER_API_KEY"]
      +
      +        r = self.client.delete("/api/credentials/pool/openrouter/1")
      +        assert r.status_code == 200
      +
      +        # Suppressed exactly like the CLI removal path.
      +        assert is_source_suppressed("openrouter", "env:OPENROUTER_API_KEY")
      +
      +        # Even if the backing var comes back (shell export, another process
      +        # rewriting .env), the removal must stay sticky.
      +        save_env_value("OPENROUTER_API_KEY", fake_key)
      +        assert load_pool("openrouter").entries() == []
      +        assert self.client.get("/api/credentials/pool").json()["providers"] == []
      +
      +    def test_post_readd_lifts_suppression(self):
      +        """Re-adding via POST is an explicit re-engagement — suppressions lift.
      +
      +        Mirrors `hermes auth add`, which clears every suppression for the
      +        provider so a user who deleted a credential and re-adds one isn't
      +        silently blocked from env re-seeding.
      +        """
      +        from agent.credential_pool import load_pool
      +        from hermes_cli.auth import is_source_suppressed
      +        from hermes_cli.config import save_env_value
      +
      +        fake_key = "sk-or-" + "y" * 20
      +        save_env_value("OPENROUTER_API_KEY", fake_key)
      +        load_pool("openrouter")
      +        assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
      +        assert is_source_suppressed("openrouter", "env:OPENROUTER_API_KEY")
      +
      +        r = self.client.post(
      +            "/api/credentials/pool",
      +            json={"provider": "openrouter", "api_key": "sk-or-" + "z" * 20},
      +        )
      +        assert r.status_code == 200
      +        assert not is_source_suppressed("openrouter", "env:OPENROUTER_API_KEY")
      +
      +        # Key back in .env + suppression lifted → env entry seeds alongside
      +        # the manual one.
      +        save_env_value("OPENROUTER_API_KEY", fake_key)
      +        sources = sorted(e.source for e in load_pool("openrouter").entries())
      +        assert sources == ["env:OPENROUTER_API_KEY", "manual"]
      +
      +    def test_manual_delete_adds_no_suppression(self):
      +        """Manual entries aren't re-seeded — CLI parity: no suppression marker."""
      +        from hermes_cli.auth import _load_auth_store
      +
      +        self.client.post(
      +            "/api/credentials/pool",
      +            json={"provider": "openrouter", "api_key": "sk-or-" + "m" * 20},
      +        )
      +        assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
      +        suppressed = _load_auth_store().get("suppressed_sources", {})
      +        assert not suppressed.get("openrouter")
      +
      +        # Immediate re-add works.
      +        r = self.client.post(
      +            "/api/credentials/pool",
      +            json={"provider": "openrouter", "api_key": "sk-or-" + "n" * 20},
      +        )
      +        assert r.status_code == 200 and r.json()["count"] == 1
      +
      +    def test_delete_does_not_clobber_other_providers(self):
      +        """Deleting one provider's env entry leaves other providers' rows alone."""
      +        from agent.credential_pool import load_pool
      +        from hermes_cli.auth import _load_auth_store, read_credential_pool
      +        from hermes_cli.config import save_env_value
      +
      +        self.client.post(
      +            "/api/credentials/pool",
      +            json={"provider": "anthropic", "api_key": "sk-ant-" + "k" * 20},
      +        )
      +        save_env_value("OPENROUTER_API_KEY", "sk-or-" + "q" * 20)
      +        load_pool("openrouter")
      +
      +        assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
      +
      +        assert len(read_credential_pool("anthropic")) == 1
      +        suppressed = _load_auth_store().get("suppressed_sources", {})
      +        assert list(suppressed.keys()) == ["openrouter"]
      +
       
       class TestMemoryEndpoints:
           @pytest.fixture(autouse=True)

3 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 1

  • #13330 fix(auth): suppress codex CLI reseed on remove · done elsewhere → b02833f, #12747, #12360
  • #13989 fix(auth_commands): normalise manual: prefix before singleton comparisons · done elsewhere → 2c69b3e
  • #55219 fix(dashboard): suppress credential source on pool delete to prevent resurrection · superseded by another PR → #55217, #67429
Closed issues · 4
  • Closed#9528openai-codex provider impossible to remove — auto-restore from ~/.codex/auth.json
  • Closed#12747auth remove openai-codex can re-seed a new device_code credential from ~/.codex/auth.json
  • Closed#13967auth remove re-seeds manually added OAuth credentials from singleton auth store
  • Closed#55217Bug: Dashboard credential pool delete succeeds but entries reappear on refresh
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.17: 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
dashboard
Merged PRs
#12360, #67429
Closed issues
4
Attempts
3 PRs closed without merging
Fix link checked
19 Jul 2026

For your agent

The same facts, typed.