diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 0ce187503c..bff262bdc0 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1077,6 +1077,13 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup ("claude_code", read_claude_code_credentials()), ): if creds and creds.get("accessToken"): + # Check if user explicitly removed this source + try: + from hermes_cli.auth import is_source_suppressed + if is_source_suppressed(provider, source_name): + continue + except ImportError: + pass active_sources.add(source_name) changed |= _upsert_entry( entries, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e984435bca..36590d617a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -704,6 +704,27 @@ def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Pa return _save_auth_store(auth_store) +def suppress_credential_source(provider_id: str, source: str) -> None: + """Mark a credential source as suppressed so it won't be re-seeded.""" + with _auth_store_lock(): + auth_store = _load_auth_store() + suppressed = auth_store.setdefault("suppressed_sources", {}) + provider_list = suppressed.setdefault(provider_id, []) + if source not in provider_list: + provider_list.append(source) + _save_auth_store(auth_store) + + +def is_source_suppressed(provider_id: str, source: str) -> bool: + """Check if a credential source has been suppressed by the user.""" + try: + auth_store = _load_auth_store() + suppressed = auth_store.get("suppressed_sources", {}) + return source in suppressed.get(provider_id, []) + except Exception: + return False + + def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: """Return persisted auth state for a provider, or None.""" auth_store = _load_auth_store() diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index eca6b2924c..0532faa770 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -347,8 +347,11 @@ def auth_remove_command(args) -> None: print("Cleared Hermes Anthropic OAuth credentials") elif removed.source == "claude_code" and provider == "anthropic": - print("Note: Claude Code credentials live in ~/.claude/.credentials.json") - print(" Remove them manually if you want to deauthorize Claude Code.") + from hermes_cli.auth import suppress_credential_source + suppress_credential_source(provider, "claude_code") + print("Suppressed claude_code credential — it will not be re-seeded.") + print("Note: Claude Code credentials still live in ~/.claude/.credentials.json") + print("Run `hermes auth add anthropic` to re-enable if needed.") def auth_reset_command(args) -> None: diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 5c4adc2f52..2ebdb1cc7e 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -657,3 +657,41 @@ def test_auth_remove_manual_entry_does_not_touch_env(tmp_path, monkeypatch): # .env should be untouched assert env_path.read_text() == "SOME_KEY=some-value\n" + + +def test_auth_remove_claude_code_suppresses_reseed(tmp_path, monkeypatch): + """Removing a claude_code credential must prevent it from being re-seeded.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr( + "agent.credential_pool._seed_from_singletons", + lambda provider, entries: (False, {"claude_code"}), + ) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + + auth_store = { + "version": 1, + "credential_pool": { + "anthropic": [{ + "id": "cc1", + "label": "claude_code", + "auth_type": "oauth", + "priority": 0, + "source": "claude_code", + "access_token": "sk-ant-oat01-token", + }] + }, + } + (hermes_home / "auth.json").write_text(json.dumps(auth_store)) + + from types import SimpleNamespace + from hermes_cli.auth_commands import auth_remove_command + auth_remove_command(SimpleNamespace(provider="anthropic", target="1")) + + updated = json.loads((hermes_home / "auth.json").read_text()) + suppressed = updated.get("suppressed_sources", {}) + assert "anthropic" in suppressed + assert "claude_code" in suppressed["anthropic"]