Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hermes_cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def _inherited_flag(parser, *args, **kwargs):
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth remove <p> <t> Remove pooled credential by index, id, label, or all
hermes auth reset <provider> Clear exhaustion status for a provider
hermes model Select default model
hermes fallback [list] Show fallback provider chain
Expand Down
71 changes: 47 additions & 24 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/subcommands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
189 changes: 189 additions & 0 deletions tests/hermes_cli/test_auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading