fix: hermes auth remove now clears env-seeded credentials permanently (#5285)
Removing an env-seeded credential (e.g. from OPENROUTER_API_KEY) via 'hermes auth' previously had no lasting effect -- the entry was deleted from auth.json but load_pool() re-created it on the next call because the env var was still set. Now auth_remove_command detects env-sourced entries (source starts with 'env:') and calls the new remove_env_value() to strip the var from both .env and os.environ, preventing re-seeding. Changes: - hermes_cli/config.py: add remove_env_value() -- atomically removes a line from .env and pops from os.environ - hermes_cli/auth_commands.py: auth_remove_command clears env var when removing an env-seeded pool entry - 8 new tests covering remove_env_value and the full zombie-credential lifecycle (remove -> reload -> stays gone)
This commit is contained in:
@@ -295,6 +295,16 @@ def auth_remove_command(args) -> None:
|
||||
raise SystemExit(f'No credential matching "{target}" for provider {provider}.')
|
||||
print(f"Removed {provider} credential #{index} ({removed.label})")
|
||||
|
||||
# If this was an env-seeded credential, also clear the env var from .env
|
||||
# so it doesn't get re-seeded on the next load_pool() call.
|
||||
if removed.source.startswith("env:"):
|
||||
env_var = removed.source[len("env:"):]
|
||||
if env_var:
|
||||
from hermes_cli.config import remove_env_value
|
||||
cleared = remove_env_value(env_var)
|
||||
if cleared:
|
||||
print(f"Cleared {env_var} from .env")
|
||||
|
||||
|
||||
def auth_reset_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
|
||||
@@ -1900,6 +1900,51 @@ def save_env_value(key: str, value: str):
|
||||
pass
|
||||
|
||||
|
||||
def remove_env_value(key: str) -> bool:
|
||||
"""Remove a key from ~/.hermes/.env and os.environ.
|
||||
|
||||
Returns True if the key was found and removed, False otherwise.
|
||||
"""
|
||||
if is_managed():
|
||||
managed_error(f"remove {key}")
|
||||
return False
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
os.environ.pop(key, None)
|
||||
return False
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
lines = _sanitize_env_lines(lines)
|
||||
|
||||
new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
|
||||
found = len(new_lines) < len(lines)
|
||||
|
||||
if found:
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
return found
|
||||
|
||||
|
||||
def save_anthropic_oauth_token(value: str, save_fn=None):
|
||||
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
|
||||
writer = save_fn or save_env_value
|
||||
|
||||
Reference in New Issue
Block a user