From 7da5f979cc14ac0fcc6651974e0e6ef93f3f70b4 Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:10:07 +0800 Subject: [PATCH] fix(auth): support removing all pooled credentials --- hermes_cli/_parser.py | 2 +- hermes_cli/auth_commands.py | 71 ++++++---- hermes_cli/subcommands/auth.py | 4 +- tests/hermes_cli/test_auth_commands.py | 189 +++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 27 deletions(-) diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 521c5fcf91def..1ee27258695fd 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -50,7 +50,7 @@ def _inherited_flag(parser, *args, **kwargs): hermes logout Clear stored authentication hermes auth add Add a pooled credential hermes auth list List pooled credentials - hermes auth remove

Remove pooled credential by index, id, or label + hermes auth remove

Remove pooled credential by index, id, label, or all hermes auth reset Clear exhaustion status for a provider hermes model Select default model hermes fallback [list] Show fallback provider chain diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index f1f87c7703ca8..8d8db928fcf02 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -467,36 +467,59 @@ def auth_remove_command(args) -> None: if target is None: target = getattr(args, "index", None) pool = load_pool(provider) + + def _finish_removed(removed) -> None: + # Unified removal dispatch. Every credential source Hermes reads from + # (env vars, external OAuth files, auth.json blocks, custom config) + # has a RemovalStep registered in agent.credential_sources. The step + # handles its source-specific cleanup and we centralise suppression + + # user-facing output here so every source behaves identically from + # the user's perspective. + from agent.credential_sources import find_removal_step + from hermes_cli.auth import suppress_credential_source + + step = find_removal_step(provider, removed.source) + if step is None: + # Unregistered source — e.g. "manual", which has nothing external + # to clean up. The pool entry is already gone; we're done. + return + + result = step.remove_fn(provider, removed) + for line in result.cleaned: + print(line) + if result.suppress: + suppress_credential_source(provider, removed.source) + for line in result.hints: + print(line) + index, matched, error = pool.resolve_target(target) + raw_target = str(target or "").strip() + target_is_all_keyword = ( + raw_target.lower() == "all" + and not any( + entry.id == raw_target or entry.label.strip().lower() == raw_target.lower() + for entry in pool.entries() + ) + ) + if matched is None and target_is_all_keyword: + entries = pool.entries() + if not entries: + raise SystemExit(f"No credentials for provider {provider}.") + for index in range(len(entries), 0, -1): + removed = pool.remove_index(index) + if removed is None: + continue + print(f"Removed {provider} credential #{index} ({removed.label})") + _finish_removed(removed) + return + if matched is None or index is None: raise SystemExit(f"{error} Provider: {provider}.") removed = pool.remove_index(index) if removed is None: raise SystemExit(f'No credential matching "{target}" for provider {provider}.') print(f"Removed {provider} credential #{index} ({removed.label})") - - # Unified removal dispatch. Every credential source Hermes reads from - # (env vars, external OAuth files, auth.json blocks, custom config) - # has a RemovalStep registered in agent.credential_sources. The step - # handles its source-specific cleanup and we centralise suppression + - # user-facing output here so every source behaves identically from - # the user's perspective. - from agent.credential_sources import find_removal_step - from hermes_cli.auth import suppress_credential_source - - step = find_removal_step(provider, removed.source) - if step is None: - # Unregistered source — e.g. "manual", which has nothing external - # to clean up. The pool entry is already gone; we're done. - return - - result = step.remove_fn(provider, removed) - for line in result.cleaned: - print(line) - if result.suppress: - suppress_credential_source(provider, removed.source) - for line in result.hints: - print(line) + _finish_removed(removed) def auth_reset_command(args) -> None: @@ -718,7 +741,7 @@ def _interactive_remove() -> None: print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]") try: - raw = input("Remove #, id, or label (blank to cancel): ").strip() + raw = input("Remove #, id, label, or all (blank to cancel): ").strip() except (EOFError, KeyboardInterrupt): return if not raw: diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index a087937cb936e..d37e185d1f0eb 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -63,11 +63,11 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_list = auth_subparsers.add_parser("list", help="List pooled credentials") auth_list.add_argument("provider", nargs="?", help="Optional provider filter") auth_remove = auth_subparsers.add_parser( - "remove", help="Remove a pooled credential by index, id, or label" + "remove", help="Remove pooled credentials by index, id, label, or all" ) auth_remove.add_argument("provider", help="Provider id") auth_remove.add_argument( - "target", help="Credential index, entry id, or exact label" + "target", help="Credential index, entry id, exact label, or 'all'" ) auth_reset = auth_subparsers.add_parser( "reset", help="Clear exhaustion status for all credentials for a provider" diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 949a936962b27..9e234f0b2e3de 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -758,6 +758,195 @@ class _Args: assert labels == ["first", "third"] +def test_auth_remove_all_removes_every_entry_and_suppresses_seeded_sources( + tmp_path, monkeypatch, capsys +): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export") + (hermes_home / ".env").write_text("") + monkeypatch.setattr( + "agent.credential_pool._seed_from_singletons", + lambda provider, entries: (False, set()), + ) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "xai": [ + { + "id": "manual-1", + "label": "manual", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-xai-manual", + }, + { + "id": "env-1", + "label": "XAI_API_KEY", + "auth_type": "api_key", + "priority": 1, + "source": "env:XAI_API_KEY", + "access_token": "sk-xai-shell-export", + }, + ] + }, + }, + ) + + from types import SimpleNamespace + from hermes_cli.auth_commands import auth_remove_command + + auth_remove_command(SimpleNamespace(provider="xai", target="all")) + + out = capsys.readouterr().out + assert "Removed xai credential #2 (XAI_API_KEY)" in out + assert "Removed xai credential #1 (manual)" in out + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["credential_pool"]["xai"] == [] + assert "env:XAI_API_KEY" in payload.get("suppressed_sources", {}).get("xai", []) + + monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export") + from agent.credential_pool import load_pool + + pool = load_pool("xai") + assert not pool.has_credentials() + + +def test_auth_remove_all_prefers_exact_label_target(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setattr( + "agent.credential_pool._seed_from_singletons", + lambda provider, entries: (False, set()), + ) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "cred-1", + "label": "all", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-all-label", + }, + { + "id": "cred-2", + "label": "keep", + "auth_type": "api_key", + "priority": 1, + "source": "manual", + "access_token": "sk-or-keep", + }, + ] + }, + }, + ) + + from types import SimpleNamespace + from hermes_cli.auth_commands import auth_remove_command + + auth_remove_command(SimpleNamespace(provider="openrouter", target="all")) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["openrouter"] + assert [entry["label"] for entry in entries] == ["keep"] + + +def test_auth_remove_all_does_not_override_ambiguous_label(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setattr( + "agent.credential_pool._seed_from_singletons", + lambda provider, entries: (False, set()), + ) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "cred-1", + "label": "all", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-one", + }, + { + "id": "cred-2", + "label": "All", + "auth_type": "api_key", + "priority": 1, + "source": "manual", + "access_token": "sk-or-two", + }, + ] + }, + }, + ) + + from types import SimpleNamespace + from hermes_cli.auth_commands import auth_remove_command + + with pytest.raises(SystemExit, match='Ambiguous credential label "all"'): + auth_remove_command(SimpleNamespace(provider="openrouter", target="all")) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["openrouter"] + assert [entry["id"] for entry in entries] == ["cred-1", "cred-2"] + + +def test_auth_remove_all_runs_provider_specific_removal_steps(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr( + "agent.credential_pool._seed_from_singletons", + lambda provider, entries: (False, set()), + ) + _write_auth_store( + tmp_path, + { + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": "tok", "refresh_token": "refresh"} + } + }, + "credential_pool": { + "openai-codex": [ + { + "id": "codex-1", + "label": "codex", + "auth_type": "oauth", + "priority": 0, + "source": "device_code", + "access_token": "tok", + } + ] + }, + }, + ) + + from types import SimpleNamespace + from hermes_cli.auth import is_source_suppressed + from hermes_cli.auth_commands import auth_remove_command + + auth_remove_command(SimpleNamespace(provider="openai-codex", target="all")) + + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["credential_pool"]["openai-codex"] == [] + assert "openai-codex" not in payload.get("providers", {}) + assert is_source_suppressed("openai-codex", "device_code") + + def test_auth_reset_clears_provider_statuses(tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store(