hermes - 💡(How to fix) Fix auth remove suppresses source even when other pool entries share it — silently gags the survivors

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

hermes auth remove openai-codex <label> calls suppress_credential_source(provider, removed.source) unconditionally, but removed.source (e.g. manual:device_code) is shared by every other manual OAuth credential the user added with hermes auth add openai-codex --type oauth. After removing one dead pool entry, the source is suppressed even though multiple healthy pool entries still carry that source — and downstream code paths that gate on is_source_suppressed() (e.g. _seed_from_singletons in agent/credential_pool.py, per PR #13427) silently drop those survivors on the next fresh pool load.

This is the inverse of the bug fixed by #13427: that PR made removal sticky (good), but the sticky behavior is now too aggressive when multiple pool entries share a source tag.

Error Message

The blast radius is silent: the user removes one credential and the others stop working at the next fresh process load, with no error telling them suppression is the cause. In our case Knox kept "working" only because suppression isn't consulted by every code path that picks credentials — but the next refactor that closes a related gap (e.g. another follow-up to #7863) would surface this as a service outage.

Root Cause

The blast radius is silent: the user removes one credential and the others stop working at the next fresh process load, with no error telling them suppression is the cause. In our case Knox kept "working" only because suppression isn't consulted by every code path that picks credentials — but the next refactor that closes a related gap (e.g. another follow-up to #7863) would surface this as a service outage.

Fix Action

Fix / Workaround

This is the inverse of the bug fixed by #13427: that PR made removal sticky (good), but the sticky behavior is now too aggressive when multiple pool entries share a source tag.

Code Example

if result.suppress:
    suppress_credential_source(provider, removed.source)

---

# Add 3 manual OAuth credentials (all get source=manual:device_code)
hermes auth add openai-codex --type oauth --label account-a
hermes auth add openai-codex --type oauth --label account-b
hermes auth add openai-codex --type oauth --label account-c

# Remove one
hermes auth remove openai-codex account-a
# Output includes:
#   Suppressed openai-codex device_code source — it will not be re-seeded.

# Inspect ~/.hermes/auth.json — suppressed_sources now contains:
#   {"openai-codex": ["device_code", "manual:device_code"]}
# …even though account-b and account-c are still in the pool with source=manual:device_code.

---

if result.suppress:
    # Only suppress when no remaining pool entries share this source.
    # Multiple manual OAuth credentials commonly share a source tag
    # (e.g. every device-code add becomes manual:device_code), so
    # suppressing on a single removal silently gags the survivors.
    has_siblings = any(e.source == removed.source for e in load_pool(provider).entries())
    if not has_siblings:
        suppress_credential_source(provider, removed.source)
    else:
        # Optional: drop the misleading "will not be re-seeded" hint when
        # we deliberately skip suppression.
        result.hints = [h for h in result.hints if "re-seeded" not in h]

---

def test_auth_remove_skips_suppression_when_other_pool_entries_share_source(tmp_path):
    pool = load_pool("openai-codex")
    for label in ("a", "b"):
        pool.add_entry(_make_codex_oauth_entry(label=label))
    auth_remove_command(SimpleNamespace(provider="openai-codex", target="a"))
    suppressed = _load_auth_store().get("suppressed_sources", {}).get("openai-codex", [])
    assert "manual:device_code" not in suppressed  # ← currently fails on main
    assert "device_code" not in suppressed
RAW_BUFFERClick to expand / collapse

Summary

hermes auth remove openai-codex <label> calls suppress_credential_source(provider, removed.source) unconditionally, but removed.source (e.g. manual:device_code) is shared by every other manual OAuth credential the user added with hermes auth add openai-codex --type oauth. After removing one dead pool entry, the source is suppressed even though multiple healthy pool entries still carry that source — and downstream code paths that gate on is_source_suppressed() (e.g. _seed_from_singletons in agent/credential_pool.py, per PR #13427) silently drop those survivors on the next fresh pool load.

This is the inverse of the bug fixed by #13427: that PR made removal sticky (good), but the sticky behavior is now too aggressive when multiple pool entries share a source tag.

Affected file

hermes_cli/auth_commands.py:344-379 — specifically:

if result.suppress:
    suppress_credential_source(provider, removed.source)

Minimal reproduction

# Add 3 manual OAuth credentials (all get source=manual:device_code)
hermes auth add openai-codex --type oauth --label account-a
hermes auth add openai-codex --type oauth --label account-b
hermes auth add openai-codex --type oauth --label account-c

# Remove one
hermes auth remove openai-codex account-a
# Output includes:
#   Suppressed openai-codex device_code source — it will not be re-seeded.

# Inspect ~/.hermes/auth.json — suppressed_sources now contains:
#   {"openai-codex": ["device_code", "manual:device_code"]}
# …even though account-b and account-c are still in the pool with source=manual:device_code.

After this, the runtime gate is_source_suppressed(provider, "manual:device_code") returns True, and any code path that consults it (or re-seeds via _seed_from_singletons) drops the survivors.

Observed in production

Hermes Agent v0.11.0 (2026.4.23), commit 2c69b3eca (PR #13427 merged). Three healthy OAuth credentials in pool, removed two dead ones (40-day and 24-day stale refresh tokens), suppression text appeared twice in the output, all three remaining credentials shared source=manual:device_code. Manual unsuppress_credential_source calls restored normal operation.

Suggested fix

auth_remove_command should only suppress the source when no other pool entries depend on it:

if result.suppress:
    # Only suppress when no remaining pool entries share this source.
    # Multiple manual OAuth credentials commonly share a source tag
    # (e.g. every device-code add becomes manual:device_code), so
    # suppressing on a single removal silently gags the survivors.
    has_siblings = any(e.source == removed.source for e in load_pool(provider).entries())
    if not has_siblings:
        suppress_credential_source(provider, removed.source)
    else:
        # Optional: drop the misleading "will not be re-seeded" hint when
        # we deliberately skip suppression.
        result.hints = [h for h in result.hints if "re-seeded" not in h]

Same shape would also apply to the canonical-key suppression where manual:device_code aliases to device_code (per #13427's mapping note).

Test sketch

def test_auth_remove_skips_suppression_when_other_pool_entries_share_source(tmp_path):
    pool = load_pool("openai-codex")
    for label in ("a", "b"):
        pool.add_entry(_make_codex_oauth_entry(label=label))
    auth_remove_command(SimpleNamespace(provider="openai-codex", target="a"))
    suppressed = _load_auth_store().get("suppressed_sources", {}).get("openai-codex", [])
    assert "manual:device_code" not in suppressed  # ← currently fails on main
    assert "device_code" not in suppressed

Why it matters

The blast radius is silent: the user removes one credential and the others stop working at the next fresh process load, with no error telling them suppression is the cause. In our case Knox kept "working" only because suppression isn't consulted by every code path that picks credentials — but the next refactor that closes a related gap (e.g. another follow-up to #7863) would surface this as a service outage.

Environment

  • Hermes Agent v0.11.0 (2026.4.23), upstream commit 2c69b3eca
  • macOS, Python 3.11.15, OpenAI SDK 2.30.0
  • openai-codex provider, manual OAuth credentials via hermes auth add openai-codex --type oauth

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING