Files
hermes-agent/agent/credential_sources.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

295 lines
12 KiB
Python

"""Unified removal contract for every credential source Hermes reads from.
Readers live in ``agent.credential_pool``; what is unified here is **removal**:
``hermes auth remove <provider> <N>`` must make the entry stay gone across
``load_pool()`` calls. Each source registers a ``RemovalStep`` whose
``remove_fn`` cleans the external state the source reads from, and the
dispatcher suppresses ``(provider, source_id)`` in auth.json so the seeding
branch skips the upsert. Adding a source: wire a reader branch in
``_seed_from_*``, gate it behind ``is_source_suppressed``, register a step here.
Also home to the one policy switch over *borrowed* CLI logins (Codex CLI's
``~/.codex/auth.json``, Claude Code's ``~/.claude/.credentials.json``):
``auth.adopt_external_logins`` in config.yaml.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Callable, List, Optional
logger = logging.getLogger(__name__)
EXTERNAL_LOGINS_NOT_ADOPTED_NOTICE = (
"External CLI logins (Codex CLI, Claude Code) are not adopted: auth.adopt_external_logins is false. "
"Hermes uses only its own logins; run `hermes auth add <provider>` to add one."
)
_notice_logged = False
def adopt_external_logins_enabled() -> bool:
"""``auth.adopt_external_logins`` (default True).
Codex and Claude OAuth refresh tokens are single-use and rotate, so once Hermes borrows a CLI's
token pair the two programs hold one token family and whichever refreshes first logs the other
out. When the user opts out, Hermes never reads or refreshes those files and says so once per
process (INFO) the first time it would have."""
global _notice_logged
try:
from hermes_cli.config import load_config_readonly
auth_cfg = (load_config_readonly() or {}).get("auth")
except Exception:
return True
enabled = not isinstance(auth_cfg, dict) or bool(auth_cfg.get("adopt_external_logins", True))
if not enabled and not _notice_logged:
_notice_logged = True
logger.info(EXTERNAL_LOGINS_NOT_ADOPTED_NOTICE)
return enabled
@dataclass
class RemovalResult:
"""Outcome of removing a credential source.
``cleaned``: external state actually mutated (printed to the user).
``hints``: diagnostics about state left intact or that the user must clean
up. ``suppress``: call ``suppress_credential_source`` afterwards so
``load_pool`` skips the source; only ``manual`` entries legitimately use False.
"""
cleaned: List[str] = field(default_factory=list)
hints: List[str] = field(default_factory=list)
suppress: bool = True
@dataclass
class RemovalStep:
"""How to remove one credential source.
``provider`` ``"*"`` matches any provider. ``match_fn`` overrides literal
``source_id`` matching (prefix patterns like ``env:*`` / ``config:*``).
``remove_fn(provider, removed_entry) -> RemovalResult``.
"""
provider: str
source_id: str
remove_fn: Callable[..., RemovalResult]
match_fn: Optional[Callable[[str], bool]] = None
description: str = ""
def matches(self, provider: str, source: str) -> bool:
if self.provider != "*" and self.provider != provider:
return False
if self.match_fn is not None:
return self.match_fn(source)
return source == self.source_id
def find_removal_step(provider: str, source: str) -> Optional[RemovalStep]:
"""First matching RemovalStep, or None (``manual``: nothing external to clean)."""
return next((step for step in _REGISTRY if step.matches(provider, source)), None)
def _remove_env_source(provider: str, removed) -> RemovalResult:
"""env:<VAR> — clear from ~/.hermes/.env; hint when the shell exports it."""
from hermes_cli.config import get_env_path, remove_env_value
result = RemovalResult()
env_var = removed.source[len("env:"):]
if not env_var:
return result
# Detect shell vs .env BEFORE remove_env_value pops os.environ. Read the
# .env as utf-8-sig like hermes_cli/config.py: a BOM-sensitive read would
# misreport a Notepad-edited .env var as a shell export.
env_in_process = bool(os.getenv(env_var))
env_in_dotenv = False
try:
env_path = get_env_path()
if env_path.exists():
env_in_dotenv = any(
line.strip().startswith(f"{env_var}=")
for line in env_path.read_text(encoding="utf-8-sig", errors="replace").splitlines()
)
except OSError:
pass
if remove_env_value(env_var):
result.cleaned.append(f"Cleared {env_var} from .env")
if env_in_process and not env_in_dotenv:
result.hints.extend([
f"Note: {env_var} is still set in your shell environment "
f"(not in ~/.hermes/.env).",
" Unset it there (shell profile, systemd EnvironmentFile, "
"launchd plist, etc.) or it will keep being visible to Hermes.",
f" The pool entry is now suppressed — Hermes will ignore "
f"{env_var} until you run `hermes auth add {provider}`.",
])
else:
result.hints.append(
f"Suppressed env:{env_var} — it will not be re-seeded even "
f"if the variable is re-exported later."
)
return result
def _remove_hermes_pkce(provider: str, removed) -> RemovalResult:
"""~/.hermes/.anthropic_oauth.json is ours — delete it outright."""
from hermes_constants import get_hermes_home
result = RemovalResult()
oauth_file = get_hermes_home() / ".anthropic_oauth.json"
if oauth_file.exists():
try:
oauth_file.unlink()
result.cleaned.append("Cleared Hermes Anthropic OAuth credentials")
except OSError as exc:
result.hints.append(f"Could not delete {oauth_file}: {exc}")
return result
def _remove_auth_store_oauth(provider: str, removed) -> RemovalResult:
"""Clear auth.json ``providers.<provider>`` (nous, minimax-oauth, xai-oauth, openai-codex).
Suppression by the dispatcher is still required — otherwise
``_seed_from_singletons`` re-seeds from any path that rewrites the block.
"""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
result = RemovalResult()
with _auth_store_lock():
auth_store = _load_auth_store()
providers_dict = auth_store.get("providers")
if isinstance(providers_dict, dict) and provider in providers_dict:
del providers_dict[provider]
_save_auth_store(auth_store)
result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store")
return result
def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
result = _remove_auth_store_oauth(provider, removed)
result.hints.append(
"Run `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) to re-authenticate if needed."
)
return result
def _remove_codex_device_code(provider: str, removed) -> RemovalResult:
"""Codex tokens also live in ~/.codex/auth.json (Codex CLI's file, kept).
Suppress the canonical ``device_code`` key — not just ``removed.source`` —
so a ``manual:device_code`` removal still blocks the re-seed path.
"""
from hermes_cli.auth import suppress_credential_source
result = _remove_auth_store_oauth(provider, removed)
suppress_credential_source(provider, "device_code")
result.hints.extend([
"Suppressed openai-codex device_code source — it will not be re-seeded.",
"Note: Codex CLI credentials still live in ~/.codex/auth.json",
"Run `hermes auth add openai-codex` to re-enable if needed.",
])
return result
def _remove_copilot_gh(provider: str, removed) -> RemovalResult:
"""The same Copilot token is seeded as gh_cli AND env:<VAR> rows, so suppress
every variant or the duplicates resurrect the entry. gh CLI and shell state
are left untouched."""
from hermes_cli.auth import suppress_credential_source
suppress_credential_source(provider, "gh_cli")
for env_var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
suppress_credential_source(provider, f"env:{env_var}")
return RemovalResult(hints=[
"Suppressed all copilot token sources (gh_cli + env vars) — they will not be re-seeded.",
"Note: Your gh CLI / shell environment is unchanged.",
"Run `hermes auth add copilot` to re-enable if needed.",
])
def _suppress_only(*hints: str) -> Callable[..., RemovalResult]:
"""remove_fn for sources whose backing file/config belongs to another tool
(Claude Code, Qwen CLI, config.yaml): never delete it, just suppress and
explain. ``{source}`` in a hint is the removed entry's source string."""
def remove_fn(provider: str, removed) -> RemovalResult:
return RemovalResult(hints=[h.format(source=removed.source) for h in hints])
return remove_fn
# ORDER MATTERS — ``find_removal_step`` returns the first match. Provider-
# specific steps precede the generic ``env:*`` step so copilot's ``env:GH_TOKEN``
# takes the copilot path (no .env edits) rather than the generic env-var removal.
_REGISTRY: List[RemovalStep] = [
RemovalStep(
provider="copilot", source_id="gh_cli",
match_fn=lambda src: src == "gh_cli" or src.startswith("env:"),
remove_fn=_remove_copilot_gh,
description="gh auth token / COPILOT_GITHUB_TOKEN / GH_TOKEN",
),
RemovalStep(
provider="*", source_id="env:",
match_fn=lambda src: src.startswith("env:"),
remove_fn=_remove_env_source,
description="Any env-seeded credential (XAI_API_KEY, DEEPSEEK_API_KEY, etc.)",
),
RemovalStep(
provider="anthropic", source_id="claude_code",
remove_fn=_suppress_only(
"Suppressed claude_code credential — it will not be re-seeded.",
"Note: Claude Code credentials still live in ~/.claude/.credentials.json",
"Run `hermes auth add anthropic` to re-enable if needed.",
),
description="~/.claude/.credentials.json",
),
RemovalStep(
provider="anthropic", source_id="hermes_pkce",
remove_fn=_remove_hermes_pkce,
description="~/.hermes/.anthropic_oauth.json",
),
RemovalStep(
provider="nous", source_id="device_code",
remove_fn=_remove_auth_store_oauth,
description="auth.json providers.nous",
),
RemovalStep(
provider="openai-codex", source_id="device_code",
match_fn=lambda src: src == "device_code" or src.endswith(":device_code"),
remove_fn=_remove_codex_device_code,
description="auth.json providers.openai-codex + ~/.codex/auth.json",
),
RemovalStep(
provider="xai-oauth", source_id="device_code",
remove_fn=_remove_xai_oauth_device_code,
description="auth.json providers.xai-oauth",
),
RemovalStep(
provider="qwen-oauth", source_id="qwen-cli",
remove_fn=_suppress_only(
"Suppressed qwen-cli credential — it will not be re-seeded.",
"Note: Qwen CLI credentials still live in ~/.qwen/oauth_creds.json",
"Run `hermes auth add qwen-oauth` to re-enable if needed.",
),
description="~/.qwen/oauth_creds.json",
),
RemovalStep(
provider="minimax-oauth", source_id="oauth",
remove_fn=_remove_auth_store_oauth,
description="auth.json providers.minimax-oauth",
),
RemovalStep(
provider="*", source_id="config:",
match_fn=lambda src: src.startswith("config:") or src == "model_config",
remove_fn=_suppress_only(
"Suppressed {source} — it will not be re-seeded.",
"Note: The underlying value in config.yaml is unchanged. Edit it "
"directly if you want to remove the credential from disk.",
),
description="Custom provider config.yaml api_key field",
),
]